2
0
mirror of https://github.com/thedevs-network/the-guard-bot synced 2025-08-29 05:07:49 +00:00
This commit is contained in:
Thomas Rory Gummerson 2023-03-08 14:08:57 +01:00
parent 0f1bcaae88
commit 37974a3d92
66 changed files with 4672 additions and 4753 deletions

View File

@ -1,26 +1,26 @@
version: 2 version: 2
jobs: jobs:
build: build:
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
working_directory: ~/repo working_directory: ~/repo
steps: steps:
- checkout - checkout
- restore_cache: - restore_cache:
keys: keys:
- v2-dependencies-{{ checksum "package-lock.json" }} - v2-dependencies-{{ checksum "package-lock.json" }}
- v2-dependencies- - v2-dependencies-
- run: npm ci - run: npm ci
- save_cache: - save_cache:
paths: paths:
- "$HOME/.npm" - '$HOME/.npm'
key: v2-dependencies-{{ checksum "package-lock.json" }} key: v2-dependencies-{{ checksum "package-lock.json" }}
- run: npm run -s typecheck - run: npm run -s typecheck
- run: npm run -s lint - run: npm run -s lint

View File

@ -18,56 +18,35 @@
"plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended", "plugin:prettier/recommended"
"prettier/@typescript-eslint"
], ],
"rules": { "rules": {
"@typescript-eslint/ban-ts-ignore": "warn",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/prefer-optional-chain": "error" "@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn"
} }
} }
], ],
"rules": { "rules": {
"indent": [ "no-mixed-spaces-and-tabs": "off",
"error", "linebreak-style": ["error", "unix"],
"tab" "semi": ["error", "always"],
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"@typescript-eslint/no-base-to-string": "error", "@typescript-eslint/no-base-to-string": "error",
"@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-floating-promises": "error",
"no-console": [ "error", { "allow": [ "assert" ] } ], "no-console": ["error", { "allow": ["assert"] }],
"for-direction": "error", "for-direction": "error",
"no-await-in-loop": "error", "no-await-in-loop": "error",
"no-extra-parens": "error",
"no-template-curly-in-string": "error", "no-template-curly-in-string": "error",
"accessor-pairs": "error", "accessor-pairs": "error",
"array-callback-return": "error", "array-callback-return": "error",
"block-scoped-var": "error", "block-scoped-var": "error",
"class-methods-use-this": "error", "class-methods-use-this": "error",
"consistent-return": "error", "consistent-return": "error",
"curly": [
"error",
"multi-line"
],
"default-case": "error", "default-case": "error",
"dot-location": [ "dot-location": ["error", "property"],
"error",
"property"
],
"dot-notation": "error", "dot-notation": "error",
"eqeqeq": "error", "eqeqeq": "error",
"no-alert": "error", "no-alert": "error",
@ -123,17 +102,12 @@
], ],
"require-await": "error", "require-await": "error",
"vars-on-top": "error", "vars-on-top": "error",
"strict": [ "strict": ["error", "global"],
"error",
"global"
],
"no-catch-shadow": "error",
"no-label-var": "error", "no-label-var": "error",
"no-shadow": "error",
"no-shadow-restricted-names": "error", "no-shadow-restricted-names": "error",
"no-undef-init": "error", "no-undef-init": "error",
"no-undefined": "error", "no-undefined": "error",
"no-use-before-define": "error", "@typescript-eslint/no-use-before-define": "error",
"global-require": "error", "global-require": "error",
"handle-callback-err": "error", "handle-callback-err": "error",
"no-buffer-constructor": "error", "no-buffer-constructor": "error",
@ -141,26 +115,16 @@
"no-new-require": "error", "no-new-require": "error",
"no-path-concat": "error", "no-path-concat": "error",
"no-process-exit": "error", "no-process-exit": "error",
"array-bracket-spacing": [
"error",
"always"
],
"block-spacing": "error", "block-spacing": "error",
"brace-style": "error", "brace-style": "error",
"comma-spacing": "error", "comma-spacing": "error",
"comma-style": "error", "comma-style": "error",
"computed-property-spacing": "error", "computed-property-spacing": "error",
"consistent-this": [ "consistent-this": ["error", "self"],
"error",
"self"
],
"eol-last": "error", "eol-last": "error",
"func-call-spacing": "error", "func-call-spacing": "error",
"func-name-matching": "error", "func-name-matching": "error",
"func-names": [ "func-names": ["error", "as-needed"],
"error",
"as-needed"
],
"func-style": [ "func-style": [
"error", "error",
"declaration", "declaration",
@ -168,12 +132,9 @@
"allowArrowFunctions": true "allowArrowFunctions": true
} }
], ],
"function-paren-newline": ["error", "multiline-arguments"],
"comma-dangle": ["warn", "always-multiline"],
"key-spacing": "error", "key-spacing": "error",
"keyword-spacing": "error", "keyword-spacing": "error",
"lines-around-comment": "error", "max-len": ["error", { "ignoreTemplateLiterals": true }],
"max-len": ["error", {"ignoreTemplateLiterals": true}],
"max-nested-callbacks": "error", "max-nested-callbacks": "error",
"max-params": "error", "max-params": "error",
"max-statements": "off", "max-statements": "off",
@ -195,10 +156,7 @@
"no-underscore-dangle": "off", "no-underscore-dangle": "off",
"no-unneeded-ternary": "error", "no-unneeded-ternary": "error",
"no-whitespace-before-property": "error", "no-whitespace-before-property": "error",
"object-curly-spacing": [ "object-curly-spacing": ["error", "always"],
"error",
"always"
],
"object-property-newline": [ "object-property-newline": [
"error", "error",
{ {
@ -206,7 +164,6 @@
} }
], ],
"operator-assignment": "error", "operator-assignment": "error",
"operator-linebreak": "error",
"quote-props": [ "quote-props": [
"error", "error",
"as-needed", "as-needed",
@ -215,15 +172,6 @@
"numbers": false "numbers": false
} }
], ],
"require-jsdoc": [
"off",
{
"require": {
"MethodDefinition": true,
"ClassDeclaration": true
}
}
],
"semi-spacing": "error", "semi-spacing": "error",
"semi-style": "error", "semi-style": "error",
"sort-vars": [ "sort-vars": [

View File

@ -1,6 +1,3 @@
{ {
"recommendations": [ "recommendations": ["editorconfig.editorconfig", "dbaeumer.vscode-eslint"]
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint"
]
} }

View File

@ -3,7 +3,6 @@
"editor.rulers": [] "editor.rulers": []
}, },
"eslint.validate": ["javascript", "typescript"], "eslint.validate": ["javascript", "typescript"],
"editor.codeActionsOnSave": { "editor.formatOnSave": true,
"source.fixAll.eslint": true "editor.defaultFormatter": "esbenp.prettier-vscode"
}
} }

View File

@ -10,27 +10,30 @@ Initially created to moderate [The Devs Network](https://thedevs.network).
**it has known issues, but it's successfully being used in production** **it has known issues, but it's successfully being used in production**
## Table of Contents ## Table of Contents
* [Key Features](#key-features)
* [Setup](#setup) - [Key Features](#key-features)
* [Commands](#commands) - [Setup](#setup)
* [Plugins](#plugins) - [Commands](#commands)
* [Support](#support) - [Plugins](#plugins)
* [License](#license) - [Support](#support)
- [License](#license)
## Key Features ## Key Features
* Synchronized across multiple groups.
* Adding admins to the bot. - Synchronized across multiple groups.
* Auto-remove and warn channels and groups ads. - Adding admins to the bot.
* Kick bots added by users. - Auto-remove and warn channels and groups ads.
* Warn and ban users to control the group. - Kick bots added by users.
* Commands work with replying, mentioning and ID. - Warn and ban users to control the group.
* Removes commands and temporary bot messages. - Commands work with replying, mentioning and ID.
* Ability to create custom commands. - Removes commands and temporary bot messages.
* Supports plugins. - Ability to create custom commands.
- Supports plugins.
Overall, keeps the groups clean and healthy to use. Overall, keeps the groups clean and healthy to use.
## Setup ## Setup
You need [Node.js](https://nodejs.org/) (>= 12) to run this bot. You need [Node.js](https://nodejs.org/) (>= 12) to run this bot.
1. Create a bot via [@BotFather](https://t.me/BotFather) and grab a **token**. 1. Create a bot via [@BotFather](https://t.me/BotFather) and grab a **token**.
@ -40,6 +43,7 @@ You need [Node.js](https://nodejs.org/) (>= 12) to run this bot.
5. Start the bot via `npm start`. 5. Start the bot via `npm start`.
### Setup with Docker ### Setup with Docker
You need to have [docker](https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/#install-from-a-package) installed on your machine. You need to have [docker](https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/#install-from-a-package) installed on your machine.
1. Create a bot via [@BotFather](https://t.me/BotFather) and grab a **token**. 1. Create a bot via [@BotFather](https://t.me/BotFather) and grab a **token**.
@ -51,29 +55,30 @@ You need to have [docker](https://docs.docker.com/engine/installation/linux/dock
Now you can add the bot as **administrator** to your groups. Now you can add the bot as **administrator** to your groups.
## Commands ## Commands
Command | Role | Available at | Description
----------------------- | ---------- | ------------ | ----------------- | Command | Role | Available at | Description |
`/admin` | _Master_ | _Everywhere_ | Makes the user admin in the bot and groups. | ----------------------- | ---------- | ------------ | ---------------------------------------------------------- |
`/unadmin` | _Master_ | _Everywhere_ | Demotes the user from admin list. | `/admin` | _Master_ | _Everywhere_ | Makes the user admin in the bot and groups. |
`/leave <name\|id>` | _Master_ | _Everywhere_ | Make the bot to leave the group cleanly. | `/unadmin` | _Master_ | _Everywhere_ | Demotes the user from admin list. |
`/hidegroup` | _Master_ | _Groups_ | Revoke invite link and hide the group from `/groups` list. | `/leave <name\|id>` | _Master_ | _Everywhere_ | Make the bot to leave the group cleanly. |
`/showgroup` | _Master_ | _Groups_ | Make the group accessible via `/groups` list. | `/hidegroup` | _Master_ | _Groups_ | Revoke invite link and hide the group from `/groups` list. |
`/del [reason]` | _Admin_ | _Everywhere_ | Deletes replied-to message. | `/showgroup` | _Master_ | _Groups_ | Make the group accessible via `/groups` list. |
`/warn <reason>` | _Admin_ | _Groups_ | Warns the user. | `/del [reason]` | _Admin_ | _Everywhere_ | Deletes replied-to message. |
`/unwarn` | _Admin_ | _Everywhere_ | Removes the last warn from the user. | `/warn <reason>` | _Admin_ | _Groups_ | Warns the user. |
`/nowarns` | _Admin_ | _Everywhere_ | Clears warns for the user. | `/unwarn` | _Admin_ | _Everywhere_ | Removes the last warn from the user. |
`/permit` | _Admin_ | _Everywhere_ | Permits the user to advertise once, within 24 hours. | `/nowarns` | _Admin_ | _Everywhere_ | Clears warns for the user. |
`/ban <reason>` | _Admin_ | _Groups_ | Bans the user from groups. | `/permit` | _Admin_ | _Everywhere_ | Permits the user to advertise once, within 24 hours. |
`/unban` | _Admin_ | _Everywhere_ | Removes the user from ban list. | `/ban <reason>` | _Admin_ | _Groups_ | Bans the user from groups. |
`/user` | _Admin_ | _Everywhere_ | Shows the status of the user. | `/unban` | _Admin_ | _Everywhere_ | Removes the user from ban list. |
`/addcommand <name>` | _Admin_ | _In-Bot_ | Create a custom command. | `/user` | _Admin_ | _Everywhere_ | Shows the status of the user. |
`/removecommand <name>` | _Admin_ | _In-Bot_ | Remove a custom command. | `/addcommand <name>` | _Admin_ | _In-Bot_ | Create a custom command. |
`/staff` | _Everyone_ | _Everywhere_ | Shows a list of admins. | `/removecommand <name>` | _Admin_ | _In-Bot_ | Remove a custom command. |
`/link` | _Everyone_ | _Everywhere_ | Shows the current group's link. | `/staff` | _Everyone_ | _Everywhere_ | Shows a list of admins. |
`/groups` | _Everyone_ | _Everywhere_ | Shows a list of groups which the bot is admin in. | `/link` | _Everyone_ | _Everywhere_ | Shows the current group's link. |
`/report` | _Everyone_ | _Everywhere_ | Reports the replied-to message to admins. | `/groups` | _Everyone_ | _Everywhere_ | Shows a list of groups which the bot is admin in. |
`/commands` | _Everyone_ | _In-Bot_ | Shows a list of available commands. | `/report` | _Everyone_ | _Everywhere_ | Reports the replied-to message to admins. |
`/help` \| `/start` | _Everyone_ | _In-Bot_ | How to use the bot. | `/commands` | _Everyone_ | _In-Bot_ | Shows a list of available commands. |
| `/help` \| `/start` | _Everyone_ | _In-Bot_ | How to use the bot. |
All commands and actions are synchronized across all of the groups managed by the owner and they work with **replying**, **mentioning** or **ID** of a user. All commands and actions are synchronized across all of the groups managed by the owner and they work with **replying**, **mentioning** or **ID** of a user.
@ -82,14 +87,14 @@ If used by reply, `/ban` and `/warn` would remove the replied-to message.
## Plugins ## Plugins
The guard is extensible in form of plugins where custom features and commands can be easily added to it. To use a plugin, put it in the [/plugins](/plugins) directory and add its name to plugins array in config.js. The guard is extensible in form of plugins where custom features and commands can be easily added to it. To use a plugin, put it in the [/plugins](/plugins) directory and add its name to plugins array in config.js.
Checkout our [Plugins' Wiki](https://github.com/thedevs-network/the-guard-bot/wiki/Plugins) page for more information. Checkout our [Plugins' Wiki](https://github.com/thedevs-network/the-guard-bot/wiki/Plugins) page for more information.
**Known plugins**: **Known plugins**:
* [Captcha](https://gist.github.com/poeti8/d84dfc4538510366a2d89294ff52b4ae): Adds a simple captcha to the bot to kick spam bots on join. - [Captcha](https://gist.github.com/poeti8/d84dfc4538510366a2d89294ff52b4ae): Adds a simple captcha to the bot to kick spam bots on join.
* [Banfiles](https://gist.github.com/poeti8/133796200d66049c9bd58e6265a52f68): Ban users that send files with specified extentions. - [Banfiles](https://gist.github.com/poeti8/133796200d66049c9bd58e6265a52f68): Ban users that send files with specified extentions.
* [Anti Arabic](https://gist.github.com/poeti8/966ccef35d61ad2735dc0120ce3e8760): Bans users that send an Arabic/Persian message. - [Anti Arabic](https://gist.github.com/poeti8/966ccef35d61ad2735dc0120ce3e8760): Bans users that send an Arabic/Persian message.
* [Anti X-POST](https://gist.github.com/poeti8/c3057f973466676ca8dbbb1183cd0624): Removes same messages sent by user across one or multiple groups. - [Anti X-POST](https://gist.github.com/poeti8/c3057f973466676ca8dbbb1183cd0624): Removes same messages sent by user across one or multiple groups.
## Support ## Support

View File

@ -16,8 +16,9 @@ module.exports = async ({ admin, reason, userToBan }) => {
await ban(userToBan, { by_id, date, reason }); await ban(userToBan, { by_id, date, reason });
await pMap(await listVisibleGroups(), group => await pMap(await listVisibleGroups(), (group) =>
telegram.kickChatMember(group.id, userToBan.id)); telegram.banChatMember(group.id, userToBan.id)
);
return html` return html`
🚫 ${lrm}${admin.first_name} <b>banned</b> ${displayUser(userToBan)}. 🚫 ${lrm}${admin.first_name} <b>banned</b> ${displayUser(userToBan)}.

View File

@ -14,7 +14,8 @@ const ms = require('millisecond');
const z = (n, d = 2) => String(n).padStart(d, '0'); const z = (n, d = 2) => String(n).padStart(d, '0');
/** @type {(d: Date) => string} */ /** @type {(d: Date) => string} */
const yyyymmdd = d => `${z(d.getFullYear(), 4)}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; const yyyymmdd = (d) =>
`${z(d.getFullYear(), 4)}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
/** @type {(a: number, b: number) => number} */ /** @type {(a: number, b: number) => number} */
const cmp = (a, b) => Math.sign(a - b); const cmp = (a, b) => Math.sign(a - b);
@ -26,7 +27,7 @@ module.exports = async ({ admin, amend, reason, userToWarn }) => {
const { warns } = await warn( const { warns } = await warn(
userToWarn, userToWarn,
{ by_id, date, reason }, { by_id, date, reason },
{ amend }, { amend }
); );
const recentWarns = warns.filter(isWarnNotExpired(date)); const recentWarns = warns.filter(isWarnNotExpired(date));
@ -35,13 +36,21 @@ module.exports = async ({ admin, amend, reason, userToWarn }) => {
'-1': html`<b>${recentWarns.length}</b>/${numberOfWarnsToBan}`, '-1': html`<b>${recentWarns.length}</b>/${numberOfWarnsToBan}`,
0: html`<b>Final warning</b>`, 0: html`<b>Final warning</b>`,
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
1: html`<b>${recentWarns.length}</b>/${numberOfWarnsToBan} (🚫 <b>banned</b>)`, 1: html`<b>${recentWarns.length}</b>/${numberOfWarnsToBan} (🚫
<b>banned</b>)`,
}[cmp(recentWarns.length + 1, numberOfWarnsToBan)]; }[cmp(recentWarns.length + 1, numberOfWarnsToBan)];
const expiryText =
typeof expireWarnsAfter === 'undefined' || expireWarnsAfter === Infinity
? ''
: `Expires on ${yyyymmdd(
new Date(date.getTime() + ms(expireWarnsAfter))
)}`;
const warnMessage = html` const warnMessage = html`
${lrm}${admin.first_name} <b>warned</b> ${link(userToWarn)}. ${lrm}${admin.first_name} <b>warned</b> ${link(userToWarn)}.
${count}: ${lrm}${reason} ${count}: ${lrm}${reason}
<i>${typeof expireWarnsAfter === 'undefined' || expireWarnsAfter === Infinity ? '' : `Expires on ${yyyymmdd(new Date(date.getTime() + ms(expireWarnsAfter)))}`}</i> <i>${expiryText}</i>
`; `;
if (recentWarns.length >= numberOfWarnsToBan) { if (recentWarns.length >= numberOfWarnsToBan) {

View File

@ -12,9 +12,10 @@ const {
deleteBansAfter = false, deleteBansAfter = false,
} = require('../utils/config').config; } = require('../utils/config').config;
const normalisedDeleteWarnsAfter = typeof deleteWarnsAfter === 'object' const normalisedDeleteWarnsAfter =
? { auto: false, manual: false, ...deleteWarnsAfter } typeof deleteWarnsAfter === 'object'
: { auto: deleteWarnsAfter, manual: deleteWarnsAfter }; ? { auto: false, manual: false, ...deleteWarnsAfter }
: { auto: deleteWarnsAfter, manual: deleteWarnsAfter };
const reply_markup = { inline_keyboard: warnInlineKeyboard }; const reply_markup = { inline_keyboard: warnInlineKeyboard };
@ -22,27 +23,30 @@ const reply_markup = { inline_keyboard: warnInlineKeyboard };
module.exports = { module.exports = {
async ban({ admin, reason, userToBan }) { async ban({ admin, reason, userToBan }) {
const banMessage = await ban({ admin, reason, userToBan }); const banMessage = await ban({ admin, reason, userToBan });
return this.loggedReply(banMessage) return this.loggedReply(banMessage).then(
.then(scheduleDeletion(deleteBansAfter)); scheduleDeletion(deleteBansAfter)
);
}, },
async batchBan({ admin, reason, targets }) { async batchBan({ admin, reason, targets }) {
const banMessage = await batchBan({ admin, reason, targets }); const banMessage = await batchBan({ admin, reason, targets });
return this.loggedReply(banMessage) return this.loggedReply(banMessage).then(
.then(scheduleDeletion(deleteBansAfter)); scheduleDeletion(deleteBansAfter)
);
}, },
async warn({ admin, amend, reason, userToWarn, mode }) { async warn({ admin, amend, reason, userToWarn, mode }) {
const warnMessage = await warn({ admin, amend, reason, userToWarn }); const warnMessage = await warn({ admin, amend, reason, userToWarn });
return this.loggedReply(warnMessage, { reply_markup }) return this.loggedReply(warnMessage, { reply_markup }).then(
.then(scheduleDeletion(normalisedDeleteWarnsAfter[mode])); scheduleDeletion(normalisedDeleteWarnsAfter[mode])
);
}, },
loggedReply(html, extra) { loggedReply(html, extra) {
if (chats.adminLog) { if (chats.adminLog) {
this.tg this.telegram
.sendMessage( .sendMessage(
chats.adminLog, chats.adminLog,
html.toJSON().replace(/\[<code>(\d+)<\/code>\]/g, '[#u$1]'), html.toJSON().replace(/\[<code>(\d+)<\/code>\]/g, '[#u$1]'),
{ parse_mode: 'HTML' }, { parse_mode: 'HTML' }
) )
.catch(() => null); .catch(() => null);
} }
@ -50,6 +54,54 @@ module.exports = {
}, },
replyWithCopy(content, options) { replyWithCopy(content, options) {
return this.telegram.sendCopy(this.chat.id, content, options); if ('text' in content) {
return this.telegram.sendMessage(this.chat.id, content.text, {
...options,
entities: content.entities,
});
}
if ('photo' in content) {
return this.telegram.sendPhoto(
this.chat.id,
content.photo.at(-1).file_id,
{
...options,
caption: content.caption,
caption_entities: content.caption_entities,
}
);
}
if ('video' in content) {
return this.telegram.sendVideo(
this.chat.id,
content.video.at(-1).file_id,
{
...options,
caption: content.caption,
caption_entities: content.caption_entities,
}
);
}
if ('video_note' in content) {
return this.telegram.sendVideoNote(
this.chat.id,
content.video_note.file_id,
{
...options,
thumb: content.video_note.thumb?.file_id,
length: content.video_note.length,
duration: content.video_note.duration,
}
);
}
return this.telegram.sendMessage(
this.chat.id,
`❌ <i>Unsupported message</i> <pre><code class="language-json">${JSON.stringify(
content,
null,
2
)}</code></pre>`,
{ ...options, parse_mode: 'HTML' }
);
}, },
}; };

View File

@ -18,10 +18,13 @@ if (process.env.NODE_ENV === 'development') {
module.exports = bot; module.exports = bot;
// Otherwise the bot can't ban on reaching max warns due to botInfo not being available to get its admin ID // Otherwise the bot can't ban on reaching max warns due to botInfo not being
Object.defineProperty(bot.context, "botInfo", { // available to get its admin ID
get () { return bot.botInfo; } Object.defineProperty(bot.context, 'botInfo', {
}) get() {
return bot.botInfo;
},
});
// cyclic dependency // cyclic dependency
// bot/index requires context requires actions/warn requires bot/index // bot/index requires context requires actions/warn requires bot/index

View File

@ -25,7 +25,6 @@
* @type {Config} * @type {Config}
*/ */
const config = { const config = {
/** /**
* @type {!( number | string | (number|string)[] )} * @type {!( number | string | (number|string)[] )}
* ID (number) or username (string) of master, * ID (number) or username (string) of master,
@ -40,9 +39,7 @@ const config = {
*/ */
token: '', token: '',
chats: { chats: {
/** /**
* @type {(number | false)} * @type {(number | false)}
* Chat to send member join/leave notifications to. * Chat to send member join/leave notifications to.
@ -67,7 +64,7 @@ const config = {
deleteCustom: { deleteCustom: {
longerThan: 450, // UTF-16 characters longerThan: 450, // UTF-16 characters
after: '20 minutes' after: '20 minutes',
}, },
/** /**

View File

@ -35,9 +35,7 @@ const roleKbRow = (cmdData) => [
const normalizeRole = (role = '') => { const normalizeRole = (role = '') => {
const lower = role.toLowerCase(); const lower = role.toLowerCase();
return lower === 'master' || lower === 'admins' return lower === 'master' || lower === 'admins' ? lower : 'everyone';
? lower
: 'everyone';
}; };
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
@ -48,7 +46,7 @@ const addCommandHandler = async (ctx) => {
if (ctx.from.status !== 'admin') { if (ctx.from.status !== 'admin') {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>Sorry, only admins access this command.</b>', ' <b>Sorry, only admins access this command.</b>'
); );
} }
@ -59,13 +57,15 @@ const addCommandHandler = async (ctx) => {
if (!isValidName) { if (!isValidName) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
'<b>Send a valid command.</b>\n\nExample:\n' + '<b>Send a valid command.</b>\n\nExample:\n' +
'<code>/addcommand rules</code>', '<code>/addcommand rules</code>'
); );
} }
const newCommand = isValidName[1].toLowerCase(); const newCommand = isValidName[1].toLowerCase();
if (preserved.has(newCommand)) { if (preserved.has(newCommand)) {
return reply('❗️ Sorry you can\'t use this name, it\'s preserved.\n\n' + return reply(
'Try another one.'); "❗️ Sorry you can't use this name, it's preserved.\n\n" +
'Try another one.'
);
} }
const replaceCmd = flags.has('replace'); const replaceCmd = flags.has('replace');
@ -76,19 +76,19 @@ const addCommandHandler = async (ctx) => {
if (!replaceCmd && cmdExists) { if (!replaceCmd && cmdExists) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>This command already exists.</b>\n\n' + ' <b>This command already exists.</b>\n\n' +
'/commands - to see the list of commands.\n' + '/commands - to see the list of commands.\n' +
'/addcommand <code>&lt;name&gt;</code> - to add a command.\n' + '/addcommand <code>&lt;name&gt;</code> - to add a command.\n' +
'/removecommand <code>&lt;name&gt;</code>' + '/removecommand <code>&lt;name&gt;</code>' +
' - to remove a command.', ' - to remove a command.',
Markup.keyboard([ [ `/addcommand -replace ${newCommand}` ] ]) Markup.keyboard([[`/addcommand -replace ${newCommand}`]])
.selective() .selective()
.oneTime() .oneTime()
.resize(), .resize()
); );
} }
if (cmdExists && cmdExists.role === 'master' && !isMaster(ctx.from)) { if (cmdExists && cmdExists.role === 'master' && !isMaster(ctx.from)) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>Sorry, only master can replace this command.</b>', ' <b>Sorry, only master can replace this command.</b>'
); );
} }
@ -102,17 +102,17 @@ const addCommandHandler = async (ctx) => {
caption: null, caption: null,
isActive: true, isActive: true,
name: newCommand, name: newCommand,
...softReplace || { content }, ...(softReplace || { content }),
}); });
return ctx.replyWithHTML( return ctx.replyWithHTML(
`✅ <b>Successfully added <code>!${isValidName[1]}</code></b>.\n` + `✅ <b>Successfully added <code>!${isValidName[1]}</code></b>.\n` +
'Who should be able to use it?', 'Who should be able to use it?',
inlineKeyboard(roleKbRow({ currentRole: role, newCommand })), inlineKeyboard(roleKbRow({ currentRole: role, newCommand }))
); );
} }
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
return ctx.replyWithHTML(' <b>Reply to a message you\'d like to save</b>'); return ctx.replyWithHTML(" <b>Reply to a message you'd like to save</b>");
}; };
module.exports = addCommandHandler; module.exports = addCommandHandler;

View File

@ -7,10 +7,7 @@ const { link, scheduleDeletion } = require('../../utils/tg');
const { parse, strip } = require('../../utils/cmd'); const { parse, strip } = require('../../utils/cmd');
// DB // DB
const { const { admin, getUser } = require('../../stores/user');
admin,
getUser,
} = require('../../stores/user');
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const adminHandler = async (ctx) => { const adminHandler = async (ctx) => {
@ -19,9 +16,9 @@ const adminHandler = async (ctx) => {
const { targets } = parse(ctx.message); const { targets } = parse(ctx.message);
if (targets.length > 1) { if (targets.length > 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user to promote.</b>', .replyWithHTML(' <b>Specify one user to promote.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const userToAdmin = targets.length const userToAdmin = targets.length
@ -29,25 +26,29 @@ const adminHandler = async (ctx) => {
: ctx.from; : ctx.from;
if (!userToAdmin) { if (!userToAdmin) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>\n' + .replyWithHTML(
'Please forward their message, then try again.', '❓ <b>User unknown.</b>\n' +
).then(scheduleDeletion()); 'Please forward their message, then try again.'
)
.then(scheduleDeletion());
} }
if (userToAdmin.status === 'banned') { if (userToAdmin.status === 'banned') {
return ctx.replyWithHTML(' <b>Can\'t admin banned user.</b>'); return ctx.replyWithHTML(" <b>Can't admin banned user.</b>");
} }
if (userToAdmin.status === 'admin') { if (userToAdmin.status === 'admin') {
return ctx.replyWithHTML( return ctx.replyWithHTML(
html`⭐️ ${link(userToAdmin)} <b>is already admin.</b>`, html`⭐️ ${link(userToAdmin)} <b>is already admin.</b>`
); );
} }
await admin(userToAdmin); await admin(userToAdmin);
return ctx.replyWithHTML(html`⭐️ ${link(userToAdmin)} <b>is now admin.</b>`); return ctx.replyWithHTML(
html`⭐️ ${link(userToAdmin)} <b>is now admin.</b>`
);
}; };
module.exports = adminHandler; module.exports = adminHandler;

View File

@ -14,7 +14,7 @@ const { getUser } = require('../../stores/user');
const banHandler = async (ctx) => { const banHandler = async (ctx) => {
if (ctx.chat.type === 'private') { if (ctx.chat.type === 'private') {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>This command is only available in groups.</b>', ' <b>This command is only available in groups.</b>'
); );
} }
@ -23,13 +23,14 @@ const banHandler = async (ctx) => {
const { flags, targets, reason } = parse(ctx.message); const { flags, targets, reason } = parse(ctx.message);
if (targets.length === 0) { if (targets.length === 0) {
return ctx.replyWithHTML( return ctx
' <b>Specify at least one user to ban.</b>', .replyWithHTML(' <b>Specify at least one user to ban.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
if (reason.length === 0) { if (reason.length === 0) {
return ctx.replyWithHTML(' <b>Need a reason to ban.</b>') return ctx
.replyWithHTML(' <b>Need a reason to ban.</b>')
.then(scheduleDeletion()); .then(scheduleDeletion());
} }
@ -37,35 +38,38 @@ const banHandler = async (ctx) => {
return ctx.batchBan({ admin: ctx.from, reason, targets }); return ctx.batchBan({ admin: ctx.from, reason, targets });
} }
const userToBan = await getUser(strip(targets[0])) || targets[0]; const userToBan = (await getUser(strip(targets[0]))) || targets[0];
if (!userToBan.id) { if (!userToBan.id) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>\n' + .replyWithHTML(
'Please forward their message, then try again.', '❓ <b>User unknown.</b>\n' +
).then(scheduleDeletion()); 'Please forward their message, then try again.'
)
.then(scheduleDeletion());
} }
if (userToBan.id === ctx.botInfo.id) return null; if (userToBan.id === ctx.botInfo.id) return null;
if (userToBan.status === 'admin') { if (userToBan.status === 'admin') {
return ctx.replyWithHTML(' <b>Can\'t ban other admins.</b>'); return ctx.replyWithHTML(" <b>Can't ban other admins.</b>");
} }
if (ctx.message.reply_to_message) { if (ctx.message.reply_to_message) {
ctx.deleteMessage(ctx.message.reply_to_message.message_id) ctx.deleteMessage(ctx.message.reply_to_message.message_id).catch(
.catch(() => null); () => null
);
} }
if (!flags.has('amend') && userToBan.status === 'banned') { if (!flags.has('amend') && userToBan.status === 'banned') {
return ctx.replyWithHTML( return ctx.replyWithHTML(
html`🚫 ${displayUser(userToBan)} <b>is already banned.</b>`, html`🚫 ${displayUser(userToBan)} <b>is already banned.</b>`
); );
} }
return ctx.ban({ return ctx.ban({
admin: ctx.from, admin: ctx.from,
reason: '[' + ctx.chat.title + '] ' + await substom(reason), reason: '[' + ctx.chat.title + '] ' + (await substom(reason)),
userToBan, userToBan,
}); });
}; };

View File

@ -49,29 +49,24 @@ const commandReferenceHandler = async (ctx) => {
const customCommandsGrouped = R.groupBy(role, customCommands); const customCommandsGrouped = R.groupBy(role, customCommands);
const userCustomCommands = customCommandsGrouped.everyone const userCustomCommands = customCommandsGrouped.everyone
? '[everyone]\n<code>' + ? '[everyone]\n<code>' +
customCommandsGrouped.everyone customCommandsGrouped.everyone.map(name).join(', ') +
.map(name) '</code>\n\n'
.join(', ') +
'</code>\n\n'
: ''; : '';
const adminCustomCommands = customCommandsGrouped.admins const adminCustomCommands = customCommandsGrouped.admins
? '[admins]\n<code>' + ? '[admins]\n<code>' +
customCommandsGrouped.admins customCommandsGrouped.admins.map(name).join(', ') +
.map(name) '</code>\n\n'
.join(', ') +
'</code>\n\n'
: ''; : '';
const masterCustomCommands = customCommandsGrouped.master const masterCustomCommands = customCommandsGrouped.master
? '[master]\n<code>' + ? '[master]\n<code>' +
customCommandsGrouped.master customCommandsGrouped.master.map(name).join(', ') +
.map(name) '</code>\n\n'
.join(', ') +
'</code>\n\n'
: ''; : '';
const customCommandsText = masterCommands.repeat(isMaster(ctx.from)) + const customCommandsText =
masterCommands.repeat(isMaster(ctx.from)) +
adminCommands.repeat(ctx.from && ctx.from.status === 'admin') + adminCommands.repeat(ctx.from && ctx.from.status === 'admin') +
userCommands + userCommands +
'\n<b>Custom commands(prefix with !):</b>\n' + '\n<b>Custom commands(prefix with !):</b>\n' +
@ -79,8 +74,7 @@ const commandReferenceHandler = async (ctx) => {
adminCustomCommands.repeat(ctx.from && ctx.from.status === 'admin') + adminCustomCommands.repeat(ctx.from && ctx.from.status === 'admin') +
userCustomCommands; userCustomCommands;
return ctx.replyWithHTML(customCommandsText) return ctx.replyWithHTML(customCommandsText).then(scheduleDeletion());
.then(scheduleDeletion());
}; };
module.exports = commandReferenceHandler; module.exports = commandReferenceHandler;

View File

@ -17,19 +17,22 @@ module.exports = async (ctx) => {
if (!(flags.has('msg_id') || ctx.message.reply_to_message)) { if (!(flags.has('msg_id') || ctx.message.reply_to_message)) {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
await ctx.replyWithHTML(' <b>Reply to a message you\'d like to delete</b>').then(scheduleDeletion()); await ctx
.replyWithHTML(" <b>Reply to a message you'd like to delete</b>")
.then(scheduleDeletion());
return; return;
} }
await ctx.telegram.deleteMessage( await ctx.telegram.deleteMessage(
flags.get('chat_id') || ctx.chat.id, flags.get('chat_id') || ctx.chat.id,
flags.get('msg_id') || ctx.message.reply_to_message.message_id, flags.get('msg_id') || ctx.message.reply_to_message.message_id
); );
if (reason) { if (reason) {
const id = R.path([ 'message', 'reply_to_message', 'from', 'id' ], ctx); const id = R.path(['message', 'reply_to_message', 'from', 'id'], ctx);
const emoji = id ? link({ id, first_name: '🗑' }) : '🗑'; const emoji = id ? link({ id, first_name: '🗑' }) : '🗑';
await ctx.replyWithHTML(html`${emoji} ${reason}`) await ctx
.replyWithHTML(html`${emoji} ${reason}`)
.then(scheduleDeletion()); .then(scheduleDeletion());
} }
}; };

View File

@ -16,9 +16,10 @@ const inline_keyboard = config.groupsInlineKeyboard;
const reply_markup = inline_keyboard && { inline_keyboard }; const reply_markup = inline_keyboard && { inline_keyboard };
const entry = group => group.username const entry = (group) =>
? `- @${group.username}` group.username
: TgHtml.tag`- <a href="${group.link}">${group.title}</a>`; ? `- @${group.username}`
: TgHtml.tag`- <a href="${group.link}">${group.title}</a>`;
const emojiRegex = XRegExp.tag('gx')` const emojiRegex = XRegExp.tag('gx')`
[\uE000-\uF8FF]| [\uE000-\uF8FF]|
@ -27,21 +28,24 @@ const emojiRegex = XRegExp.tag('gx')`
[\u2011-\u26FF]| [\u2011-\u26FF]|
\uD83E[\uDD10-\uDDFF]`; \uD83E[\uDD10-\uDDFF]`;
const stripEmoji = s => s.replace(emojiRegex, ''); const stripEmoji = (s) => s.replace(emojiRegex, '');
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const groupsHandler = async (ctx) => { const groupsHandler = async (ctx) => {
const groups = await listVisibleGroups(); const groups = await listVisibleGroups();
groups.sort((a, b) => groups.sort((a, b) =>
stripEmoji(a.title).localeCompare(stripEmoji(b.title))); stripEmoji(a.title).localeCompare(stripEmoji(b.title))
);
const entries = TgHtml.join('\n', groups.map(entry)); const entries = TgHtml.join('\n', groups.map(entry));
return ctx.replyWithHTML(TgHtml.tag`🛠 <b>Groups I manage</b>:\n\n${entries}`, { return ctx
disable_web_page_preview: true, .replyWithHTML(TgHtml.tag`🛠 <b>Groups I manage</b>:\n\n${entries}`, {
reply_markup, disable_web_page_preview: true,
}).then(scheduleDeletion()); reply_markup,
})
.then(scheduleDeletion());
}; };
module.exports = groupsHandler; module.exports = groupsHandler;

View File

@ -25,7 +25,7 @@ const helpHandler = (ctx) => {
return ctx.replyWithHTML( return ctx.replyWithHTML(
message, message,
Markup.inlineKeyboard([ Markup.inlineKeyboard([
Markup.button.url('🛠 Setup a New Bot', homepage) Markup.button.url('🛠 Setup a New Bot', homepage),
]) ])
); );
}; };

View File

@ -13,6 +13,6 @@ module.exports = router;
const exclude = (_, filename) => filename === 'routingFn.js'; const exclude = (_, filename) => filename === 'routingFn.js';
const rename = R.toLower; const rename = R.toLower;
const extensions = [ 'js', 'ts' ]; const extensions = ['js', 'ts'];
const handlers = requireDir(module, { exclude, extensions, rename }); const handlers = requireDir(module, { exclude, extensions, rename });
router.handlers = new Map(Object.entries(handlers)); router.handlers = new Map(Object.entries(handlers));

View File

@ -1,12 +1,14 @@
import { managesGroup, removeGroup } from "../../stores/group"; import { managesGroup, removeGroup } from '../../stores/group';
import { ExtendedContext } from "../../typings/context"; import { ExtendedContext } from '../../typings/context';
import { html } from "../../utils/html"; import { html } from '../../utils/html';
import { isMaster } from "../../utils/config"; import { isMaster } from '../../utils/config';
const leaveCommandHandler = async (ctx: ExtendedContext) => { const leaveCommandHandler = async (ctx: ExtendedContext) => {
if (!isMaster(ctx.from)) return null; if (!isMaster(ctx.from)) return null;
if (typeof ctx.message === 'undefined') return null;
if (!('text' in ctx.message)) return null;
const query = ctx.message!.text!.split(" ").slice(1).join(" "); const query = ctx.message.text.split(' ').slice(1).join(' ');
const group = query const group = query
? await managesGroup( ? await managesGroup(
@ -14,7 +16,7 @@ const leaveCommandHandler = async (ctx: ExtendedContext) => {
) )
: ctx.chat; : ctx.chat;
if (!group) { if (!group) {
return ctx.replyWithHTML("❓ <b>Unknown group.</b>"); return ctx.replyWithHTML('❓ <b>Unknown group.</b>');
} }
await removeGroup(group); await removeGroup(group);

View File

@ -14,9 +14,11 @@ const linkHandler = async (ctx, next) => {
const group = await managesGroup({ id: ctx.chat.id }); const group = await managesGroup({ id: ctx.chat.id });
return ctx.replyWithHTML(group.link || ' <b>No link to this group</b>', { return ctx
disable_web_page_preview: false, .replyWithHTML(group.link || ' <b>No link to this group</b>', {
}).then(scheduleDeletion()); disable_web_page_preview: false,
})
.then(scheduleDeletion());
}; };
module.exports = linkHandler; module.exports = linkHandler;

View File

@ -17,43 +17,48 @@ const { listGroups } = require('../../stores/group');
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const nowarnsHandler = async (ctx) => { const nowarnsHandler = async (ctx) => {
if (ctx.from?.status !== 'admin') return null; if (ctx.from?.status !== 'admin') return null;
if (typeof ctx.message === 'undefined') return null;
if (!('text' in ctx.message)) return null;
const { targets } = parse(ctx.message); const { targets } = parse(ctx.message);
if (targets.length !== 1) { if (targets.length !== 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user to pardon.</b>', .replyWithHTML(' <b>Specify one user to pardon.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const userToUnwarn = await getUser(strip(targets[0])); const userToUnwarn = await getUser(strip(targets[0]));
if (!userToUnwarn) { if (!userToUnwarn) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>', .replyWithHTML('❓ <b>User unknown.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const { warns } = userToUnwarn; const { warns } = userToUnwarn;
if (warns.length === 0) { if (warns.length === 0) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
html` ${link(userToUnwarn)} <b>already has no warnings.</b>`, html` ${link(userToUnwarn)} <b>already has no warnings.</b>`
); );
} }
if (userToUnwarn.status === 'banned') { if (userToUnwarn.status === 'banned') {
await pMap(await listGroups({ type: 'supergroup' }), (group) => await pMap(await listGroups({ type: 'supergroup' }), (group) =>
ctx.telegram.unbanChatMember(group.id, userToUnwarn.id)); ctx.telegram.unbanChatMember(group.id, userToUnwarn.id)
);
} }
await nowarns(userToUnwarn); await nowarns(userToUnwarn);
if (userToUnwarn.status === 'banned') { if (userToUnwarn.status === 'banned') {
ctx.telegram.sendMessage( ctx.telegram
userToUnwarn.id, .sendMessage(
'♻️ You were unbanned from all of the /groups!', userToUnwarn.id,
).catch(() => null); '♻️ You were unbanned from all of the /groups!'
)
.catch(() => null);
// it's likely that the banned person haven't PMed the bot, // it's likely that the banned person haven't PMed the bot,
// which will cause the sendMessage to fail, // which will cause the sendMessage to fail,
// hance .catch(noop) // hance .catch(noop)
@ -66,5 +71,4 @@ const nowarnsHandler = async (ctx) => {
`); `);
}; };
module.exports = nowarnsHandler; module.exports = nowarnsHandler;

View File

@ -1,16 +1,18 @@
import { displayUser, scheduleDeletion } from "../../utils/tg"; import { displayUser, scheduleDeletion } from '../../utils/tg';
import { html, lrm } from "../../utils/html"; import { html, lrm } from '../../utils/html';
import { parse, strip } from "../../utils/cmd"; import { parse, strip } from '../../utils/cmd';
import type { ExtendedContext } from "../../typings/context"; import type { ExtendedContext } from '../../typings/context';
import { permit } from "../../stores/user"; import { permit } from '../../stores/user';
export = async (ctx: ExtendedContext) => { export = async (ctx: ExtendedContext) => {
if (ctx.from?.status !== "admin") return null; if (ctx.from?.status !== 'admin') return null;
if (typeof ctx.message === 'undefined') return null;
if (!('text' in ctx.message)) return null;
const { targets } = parse(ctx.message); const { targets } = parse(ctx.message);
if (targets.length !== 1) { if (targets.length !== 1) {
return ctx return ctx
.replyWithHTML(" <b>Specify one user to permit.</b>") .replyWithHTML(' <b>Specify one user to permit.</b>')
.then(scheduleDeletion()); .then(scheduleDeletion());
} }
@ -20,7 +22,9 @@ export = async (ctx: ExtendedContext) => {
}); });
return ctx.replyWithHTML(html` return ctx.replyWithHTML(html`
🎟 ${lrm}${ctx.from.first_name} <b>permitted</b> ${displayUser(permitted)} to 🎟 ${lrm}${ctx.from.first_name} <b>permitted</b> ${displayUser(
promote once within the next 24 hours! permitted
)}
to promote once within the next 24 hours!
`); `);
}; };

View File

@ -12,35 +12,33 @@ const removeCommandHandler = async (ctx) => {
if (ctx.from.status !== 'admin') { if (ctx.from.status !== 'admin') {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>Sorry, only admins access this command.</b>', ' <b>Sorry, only admins access this command.</b>'
); );
} }
const [ , commandName ] = text.split(' '); const [, commandName] = text.split(' ');
if (!commandName) { if (!commandName) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
'<b>Send a valid command.</b>\n\nExample:\n' + '<b>Send a valid command.</b>\n\nExample:\n' +
'<code>/removecommand rules</code>', '<code>/removecommand rules</code>'
); );
} }
const command = await getCommand({ name: commandName.toLowerCase() }); const command = await getCommand({ name: commandName.toLowerCase() });
if (!command) { if (!command) {
return ctx.replyWithHTML( return ctx.replyWithHTML(" <b>Command couldn't be found.</b>");
' <b>Command couldn\'t be found.</b>',
);
} }
const role = command.role.toLowerCase(); const role = command.role.toLowerCase();
if (role === 'master' && !isMaster(ctx.from)) { if (role === 'master' && !isMaster(ctx.from)) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>Sorry, only master can remove this command.</b>', ' <b>Sorry, only master can remove this command.</b>'
); );
} }
await removeCommand({ name: commandName.toLowerCase() }); await removeCommand({ name: commandName.toLowerCase() });
return ctx.replyWithHTML( return ctx.replyWithHTML(
`✅ <code>!${commandName}</code> ` + `✅ <code>!${commandName}</code> ` +
'<b>has been removed successfully.</b>', '<b>has been removed successfully.</b>'
); );
}; };

View File

@ -3,38 +3,40 @@
// Utils // Utils
const Cmd = require('../../utils/cmd'); const Cmd = require('../../utils/cmd');
const { TgHtml } = require('../../utils/html'); const { TgHtml } = require('../../utils/html');
const { const { link, msgLink, scheduleDeletion } = require('../../utils/tg');
link,
msgLink,
scheduleDeletion,
} = require('../../utils/tg');
const { chats = {} } = require('../../utils/config').config; const { chats = {} } = require('../../utils/config').config;
const isQualified = member => member.status === 'creator' || const isQualified = (member) =>
member.can_delete_messages && member.status === 'creator' ||
member.can_restrict_members; (member.can_delete_messages && member.can_restrict_members);
const adminMention = ({ user }) => const adminMention = ({ user }) =>
TgHtml.tag`<a href="tg://user?id=${user.id}">&#8203;</a>`; TgHtml.tag`<a href="tg://user?id=${user.id}">&#8203;</a>`;
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const reportHandler = async ctx => { const reportHandler = async (ctx) => {
if (!ctx.chat.type.endsWith('group')) return null; if (!ctx.chat.type.endsWith('group')) return null;
// Ignore monospaced reports // Ignore monospaced reports
if (ctx.message.entities?.[0]?.type === 'code' && ctx.message.entities[0].offset === 0) if (
ctx.message.entities?.[0]?.type === 'code' &&
ctx.message.entities[0].offset === 0
) {
return null; return null;
}
if (!ctx.message.reply_to_message) { if (!ctx.message.reply_to_message) {
await ctx.deleteMessage(); await ctx.deleteMessage();
return ctx.replyWithHTML( return ctx
' <b>Reply to message you\'d like to report</b>', .replyWithHTML(" <b>Reply to message you'd like to report</b>")
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const admins = (await ctx.getChatAdministrators()) const admins = (await ctx.getChatAdministrators())
.filter(isQualified) .filter(isQualified)
.map(adminMention); .map(adminMention);
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const s = TgHtml.tag`❗️ <b>Message from ${link(ctx.message.reply_to_message.from)} was reported to the admins</b>.${TgHtml.join('', admins)}`; const s = TgHtml.tag`❗️ <b>Message from ${link(
ctx.message.reply_to_message.from
)} was reported to the admins</b>.${TgHtml.join('', admins)}`;
const report = await ctx.replyWithHTML(s, { const report = await ctx.replyWithHTML(s, {
reply_to_message_id: ctx.message.reply_to_message.message_id, reply_to_message_id: ctx.message.reply_to_message.message_id,
}); });
@ -43,21 +45,30 @@ const reportHandler = async ctx => {
await ctx.telegram.sendMessage( await ctx.telegram.sendMessage(
chats.report, chats.report,
TgHtml.tag`❗️ ${link(ctx.from)} reported <a href="${msgLink( TgHtml.tag`❗️ ${link(ctx.from)} reported <a href="${msgLink(
ctx.message.reply_to_message, ctx.message.reply_to_message
)}">a message</a> from ${link(ctx.message.reply_to_message.from)} in ${ctx.chat.title}!`, )}">a message</a> from ${link(
ctx.message.reply_to_message.from
)} in ${ctx.chat.title}!`,
{ {
parse_mode: 'HTML', parse_mode: 'HTML',
reply_markup: { inline_keyboard: [ [ { reply_markup: {
text: '✔️ Handled', inline_keyboard: [
callback_data: Cmd.stringify({ [
command: 'del', {
flags: { text: '✔️ Handled',
chat_id: report.chat.id, callback_data: Cmd.stringify({
msg_id: report.message_id, command: 'del',
}, flags: {
reason: 'Report handled', chat_id: report.chat.id,
}), msg_id: report.message_id,
} ] ] } }, },
reason: 'Report handled',
}),
},
],
],
},
}
); );
} }
return null; return null;

View File

@ -7,8 +7,9 @@ const { isCommand } = require('../../utils/tg');
module.exports = ({ me, message }) => { module.exports = ({ me, message }) => {
if (!isCommand(message)) return null; if (!isCommand(message)) return null;
const [ , command, username ] = const [, command, username] = /^\/(?:start )?(\w+)(@\w+)?/.exec(
/^\/(?:start )?(\w+)(@\w+)?/.exec(message.text); message.text
);
if (username && !eq.username(username, me)) return null; if (username && !eq.username(username, me)) return null;

View File

@ -8,18 +8,28 @@ const { quietLink, scheduleDeletion } = require('../../utils/tg');
const { getAdmins } = require('../../stores/user'); const { getAdmins } = require('../../stores/user');
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const staffHandler = async ctx => { const staffHandler = async (ctx) => {
const admins = await getAdmins(); const admins = await getAdmins();
admins.sort((a, b) => a.first_name > b.first_name ? 1 : -1); admins.sort((a, b) => (a.first_name > b.first_name ? 1 : -1));
const links = admins.map(quietLink); const links = admins.map(quietLink);
const list = TgHtml.join('\n', links.map(s => html`${s}`)); const list = TgHtml.join(
'\n',
links.map((s) => html`${s}`)
);
return ctx.replyWithHTML(html`<b>Admins in the network:</b>\n\n${list}`, { return ctx
disable_notification: true, .replyWithHTML(
disable_web_page_preview: true, html`<b>Admins in the network:</b>
}).then(scheduleDeletion());
${list}`,
{
disable_notification: true,
disable_web_page_preview: true,
}
)
.then(scheduleDeletion());
}; };
module.exports = staffHandler; module.exports = staffHandler;

View File

@ -17,14 +17,16 @@ const noop = Function.prototype;
const tgUnadmin = async (userToUnadmin) => { const tgUnadmin = async (userToUnadmin) => {
for (const group of await listGroups()) { for (const group of await listGroups()) {
telegram.promoteChatMember(group.id, userToUnadmin.id, { telegram
can_change_info: false, .promoteChatMember(group.id, userToUnadmin.id, {
can_delete_messages: false, can_change_info: false,
can_invite_users: false, can_delete_messages: false,
can_pin_messages: false, can_invite_users: false,
can_promote_members: false, can_pin_messages: false,
can_restrict_members: false, can_promote_members: false,
}).catch(noop); can_restrict_members: false,
})
.catch(noop);
} }
}; };
@ -35,22 +37,22 @@ const unAdminHandler = async (ctx) => {
const { targets } = parse(ctx.message); const { targets } = parse(ctx.message);
if (targets.length !== 1) { if (targets.length !== 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user to unadmin.</b>', .replyWithHTML(' <b>Specify one user to unadmin.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const userToUnadmin = await getUser(strip(targets[0])); const userToUnadmin = await getUser(strip(targets[0]));
if (!userToUnadmin) { if (!userToUnadmin) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>', .replyWithHTML('❓ <b>User unknown.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
if (userToUnadmin.status !== 'admin') { if (userToUnadmin.status !== 'admin') {
return ctx.replyWithHTML( return ctx.replyWithHTML(
html` ${link(userToUnadmin)} <b>is not admin.</b>`, html` ${link(userToUnadmin)} <b>is not admin.</b>`
); );
} }
@ -59,7 +61,7 @@ const unAdminHandler = async (ctx) => {
await unadmin(userToUnadmin); await unadmin(userToUnadmin);
return ctx.replyWithHTML( return ctx.replyWithHTML(
html`❗️ ${link(userToUnadmin)} <b>is no longer admin.</b>`, html`❗️ ${link(userToUnadmin)} <b>is no longer admin.</b>`
); );
}; };

View File

@ -14,45 +14,50 @@ const { getUser, unban } = require('../../stores/user');
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const unbanHandler = async (ctx) => { const unbanHandler = async (ctx) => {
if (ctx.from?.status !== 'admin') return null; if (ctx.from?.status !== 'admin') return null;
if (typeof ctx.message === 'undefined') return null;
if (!('text' in ctx.message)) return null;
const { targets } = parse(ctx.message); const { targets } = parse(ctx.message);
if (targets.length !== 1) { if (targets.length !== 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user to unban.</b>', .replyWithHTML(' <b>Specify one user to unban.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const userToUnban = await getUser(strip(targets[0])); const userToUnban = await getUser(strip(targets[0]));
if (!userToUnban) { if (!userToUnban) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>', .replyWithHTML('❓ <b>User unknown.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
if (userToUnban.status !== 'banned') { if (userToUnban.status !== 'banned') {
return ctx.replyWithHTML(' <b>User is not banned.</b>'); return ctx.replyWithHTML(' <b>User is not banned.</b>');
} }
await pMap(await listGroups({ type: 'supergroup' }), (group) => await pMap(await listGroups({ type: 'supergroup' }), (group) =>
ctx.telegram.unbanChatMember(group.id, userToUnban.id)); ctx.telegram.unbanChatMember(group.id, userToUnban.id)
);
await unban(userToUnban); await unban(userToUnban);
ctx.telegram.sendMessage( ctx.telegram
userToUnban.id, .sendMessage(
'♻️ You were unbanned from all of the /groups!', userToUnban.id,
).catch(() => null); '♻️ You were unbanned from all of the /groups!'
)
.catch(() => null);
// it's likely that the banned person haven't PMed the bot, // it's likely that the banned person haven't PMed the bot,
// which will cause the sendMessage to fail, // which will cause the sendMessage to fail,
// hance .catch(noop) // hance .catch(noop)
// (it's an expected, non-critical failure) // (it's an expected, non-critical failure)
return ctx.loggedReply(html` return ctx.loggedReply(html`
${lrm}${ctx.from.first_name} <b>unbanned</b> ${displayUser(userToUnban)}. ${lrm}${ctx.from.first_name} <b>unbanned</b> ${displayUser(
userToUnban
)}.
`); `);
}; };

View File

@ -32,34 +32,37 @@ $`;
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const unwarnHandler = async (ctx) => { const unwarnHandler = async (ctx) => {
if (ctx.from?.status !== 'admin') return null; if (ctx.from?.status !== 'admin') return null;
if (typeof ctx.message === 'undefined') return null;
if (!('text' in ctx.message)) return null;
const { reason, targets } = parse(ctx.message); const { reason, targets } = parse(ctx.message);
if (targets.length !== 1) { if (targets.length !== 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user to unwarn.</b>', .replyWithHTML(' <b>Specify one user to unwarn.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const userToUnwarn = await getUser(strip(targets[0])); const userToUnwarn = await getUser(strip(targets[0]));
if (!userToUnwarn) { if (!userToUnwarn) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown</b>', .replyWithHTML('❓ <b>User unknown</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const allWarns = userToUnwarn.warns.filter(isWarnNotExpired(new Date())); const allWarns = userToUnwarn.warns.filter(isWarnNotExpired(new Date()));
if (allWarns.length === 0) { if (allWarns.length === 0) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
html` ${link(userToUnwarn)} <b>already has no warnings.</b>`, html` ${link(userToUnwarn)} <b>already has no warnings.</b>`
); );
} }
if (userToUnwarn.status === 'banned') { if (userToUnwarn.status === 'banned') {
await pMap(await listGroups({ type: 'supergroup' }), group => await pMap(await listGroups({ type: 'supergroup' }), (group) =>
ctx.telegram.unbanChatMember(group.id, userToUnwarn.id)); ctx.telegram.unbanChatMember(group.id, userToUnwarn.id)
);
} }
let lastWarn; let lastWarn;
@ -67,27 +70,30 @@ const unwarnHandler = async (ctx) => {
lastWarn = last(allWarns); lastWarn = last(allWarns);
} else if (dateRegex.test(reason)) { } else if (dateRegex.test(reason)) {
const normalized = reason.replace(' ', 'T').toUpperCase(); const normalized = reason.replace(' ', 'T').toUpperCase();
lastWarn = allWarns.find(({ date }) => lastWarn = allWarns.find(
date && date.toISOString().startsWith(normalized)); ({ date }) => date && date.toISOString().startsWith(normalized)
);
} else { } else {
return ctx.replyWithHTML( return ctx
'⚠ <b>Invalid date</b>', .replyWithHTML('⚠ <b>Invalid date</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
if (!lastWarn) { if (!lastWarn) {
return ctx.replyWithHTML( return ctx
'❓ <b>404: Warn not found</b>', .replyWithHTML('❓ <b>404: Warn not found</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
await unwarn(userToUnwarn, lastWarn); await unwarn(userToUnwarn, lastWarn);
if (userToUnwarn.status === 'banned') { if (userToUnwarn.status === 'banned') {
ctx.telegram.sendMessage( ctx.telegram
userToUnwarn.id, .sendMessage(
'♻️ You were unbanned from all of the /groups!', userToUnwarn.id,
).catch(() => null); '♻️ You were unbanned from all of the /groups!'
)
.catch(() => null);
// it's likely that the banned person haven't PMed the bot, // it's likely that the banned person haven't PMed the bot,
// which will cause the sendMessage to fail, // which will cause the sendMessage to fail,
// hance .catch(noop) // hance .catch(noop)
@ -97,10 +103,9 @@ const unwarnHandler = async (ctx) => {
const count = html`<b>${allWarns.length}</b>/${numberOfWarnsToBan}`; const count = html`<b>${allWarns.length}</b>/${numberOfWarnsToBan}`;
return ctx.loggedReply(html` return ctx.loggedReply(html`
${lrm}${ctx.from.first_name} <b>pardoned</b> ${link(userToUnwarn)} for ${lrm}${ctx.from.first_name} <b>pardoned</b> ${link(userToUnwarn)}
${count}: ${lrm}${lastWarn.reason || lastWarn} for ${count}: ${lrm}${lastWarn.reason || lastWarn}
`); `);
}; };
module.exports = unwarnHandler; module.exports = unwarnHandler;

View File

@ -12,7 +12,7 @@ const { parse, strip } = require('../../utils/cmd');
const { getUser, permit } = require('../../stores/user'); const { getUser, permit } = require('../../stores/user');
/** @param {Date} date */ /** @param {Date} date */
const formatDate = date => const formatDate = (date) =>
date && date.toISOString().slice(0, -5).replace('T', ' ') + ' UTC'; date && date.toISOString().slice(0, -5).replace('T', ' ') + ' UTC';
/** /**
@ -20,8 +20,9 @@ const formatDate = date =>
*/ */
const formatEntry = async (entry, defaultVal) => { const formatEntry = async (entry, defaultVal) => {
if (!entry || !entry.by_id) return html`${defaultVal}`; if (!entry || !entry.by_id) return html`${defaultVal}`;
const { first_name } = await getUser({ id: entry.by_id }) || {}; const { first_name } = (await getUser({ id: entry.by_id })) || {};
if (!first_name) return html`${lrm}${entry.reason} (${formatDate(entry.date)})`; if (!first_name)
return html`${lrm}${entry.reason} (${formatDate(entry.date)})`;
return html`${lrm}${entry.reason} (${first_name}, ${formatDate(entry.date)})`; return html`${lrm}${entry.reason} (${first_name}, ${formatDate(entry.date)})`;
}; };
@ -33,14 +34,12 @@ const formatWarn = async (warn, i) =>
/** /**
* @param {string | TgHtml | undefined } content * @param {string | TgHtml | undefined } content
*/ */
const isNotEmpty = content => content?.length; const isNotEmpty = (content) => content?.length;
const optional = (header, sep, content) => const optional = (header, sep, content) =>
isNotEmpty(content) isNotEmpty(content) ? html`${header}${sep}${content}` : html``;
? html`${header}${sep}${content}`
: html``;
const title = user => { const title = (user) => {
if (isMaster(user)) { if (isMaster(user)) {
return html`🕴️ <b>Bot master</b>`; return html`🕴️ <b>Bot master</b>`;
} else if (user.status === 'admin') { } else if (user.status === 'admin') {
@ -52,70 +51,80 @@ const title = user => {
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const getWarnsHandler = async (ctx) => { const getWarnsHandler = async (ctx) => {
if (!ctx.from) { if (!ctx.from) {
return ctx.replyWithHTML( return ctx
' <b>This command is not available in channels.</b>', .replyWithHTML(
).then(scheduleDeletion()); ' <b>This command is not available in channels.</b>'
)
.then(scheduleDeletion());
} }
if (typeof ctx.message === 'undefined') return;
if (!('text' in ctx.message)) return;
const { flags, targets } = parse(ctx.message); const { flags, targets } = parse(ctx.message);
if (targets.length > 1) { if (targets.length > 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user.</b>', .replyWithHTML(' <b>Specify one user.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const theUser = targets.length && ctx.from.status === 'admin' const theUser =
? await getUser(strip(targets[0])) targets.length && ctx.from.status === 'admin'
: ctx.from; ? await getUser(strip(targets[0]))
: ctx.from;
if (!theUser) { if (!theUser) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>', .replyWithHTML('❓ <b>User unknown.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
if (flags.has('raw') && ctx.from.status === 'admin') { if (flags.has('raw') && ctx.from.status === 'admin') {
return ctx.replyWithHTML( return ctx
TgHtml.pre(inspect(theUser)), .replyWithHTML(TgHtml.pre(inspect(theUser)))
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const header = html`${title(theUser)} ${displayUser(theUser)}`; const header = html`${title(theUser)} ${displayUser(theUser)}`;
const banReason = optional( const banReason = optional(
html`🚫 <b>Ban reason:</b>`, html`🚫 <b>Ban reason:</b>`,
' ', ' ',
await formatEntry(theUser.ban_details, theUser.ban_reason || ''), await formatEntry(theUser.ban_details, theUser.ban_reason || '')
); );
const { warns = [] } = theUser; const { warns = [] } = theUser;
const userWarns = optional( const userWarns = optional(
html`<b>⚠️ Warns:</b>`, html`<b>⚠️ Warns:</b>`,
'\n', '\n',
TgHtml.join('\n', await Promise.all(warns.map(formatWarn))), TgHtml.join('\n', await Promise.all(warns.map(formatWarn)))
); );
const firstSeen = optional( const firstSeen = optional(
html`👀 <b>First seen:</b>`, html`👀 <b>First seen:</b>`,
' ', ' ',
formatDate(theUser.createdAt), formatDate(theUser.createdAt)
); );
const permits = permit.isValid(theUser.permit) const permits = permit.isValid(theUser.permit)
// eslint-disable-next-line max-len ? // eslint-disable-next-line max-len
? `🎟 ${(await getUser({ id: theUser.permit.by_id })).first_name}, ${formatDate(theUser.permit.date)}` `🎟 ${
(await getUser({ id: theUser.permit.by_id })).first_name
}, ${formatDate(theUser.permit.date)}`
: ''; : '';
const oneliners = TgHtml.join('\n', [ const oneliners = TgHtml.join(
header, '\n',
firstSeen, [header, firstSeen, permits].filter(isNotEmpty)
permits, );
].filter(isNotEmpty));
return ctx.replyWithHTML(TgHtml.join('\n\n', [ return ctx
oneliners, .replyWithHTML(
userWarns, TgHtml.join(
banReason, '\n\n',
].filter(isNotEmpty))).then(scheduleDeletion()); [oneliners, userWarns, banReason].filter(isNotEmpty)
)
)
.then(scheduleDeletion());
}; };
module.exports = getWarnsHandler; module.exports = getWarnsHandler;

View File

@ -11,7 +11,7 @@ const { getUser } = require('../../stores/user');
const warnHandler = async (ctx) => { const warnHandler = async (ctx) => {
if (!ctx.message.chat.type.endsWith('group')) { if (!ctx.message.chat.type.endsWith('group')) {
return ctx.replyWithHTML( return ctx.replyWithHTML(
' <b>This command is only available in groups.</b>', ' <b>This command is only available in groups.</b>'
); );
} }
@ -20,40 +20,44 @@ const warnHandler = async (ctx) => {
const { flags, reason, targets } = parse(ctx.message); const { flags, reason, targets } = parse(ctx.message);
if (targets.length !== 1) { if (targets.length !== 1) {
return ctx.replyWithHTML( return ctx
' <b>Specify one user to warn.</b>', .replyWithHTML(' <b>Specify one user to warn.</b>')
).then(scheduleDeletion()); .then(scheduleDeletion());
} }
const userToWarn = await getUser(strip(targets[0])); const userToWarn = await getUser(strip(targets[0]));
if (!userToWarn) { if (!userToWarn) {
return ctx.replyWithHTML( return ctx
'❓ <b>User unknown.</b>\n' + .replyWithHTML(
'Please forward their message, then try again.', '❓ <b>User unknown.</b>\n' +
).then(scheduleDeletion()); 'Please forward their message, then try again.'
)
.then(scheduleDeletion());
} }
if (userToWarn.id === ctx.botInfo.id) return null; if (userToWarn.id === ctx.botInfo.id) return null;
if (userToWarn.status === 'admin') { if (userToWarn.status === 'admin') {
return ctx.replyWithHTML(' <b>Can\'t warn other admins.</b>'); return ctx.replyWithHTML(" <b>Can't warn other admins.</b>");
} }
if (reason.length === 0) { if (reason.length === 0) {
return ctx.replyWithHTML(' <b>Need a reason to warn.</b>') return ctx
.replyWithHTML(' <b>Need a reason to warn.</b>')
.then(scheduleDeletion()); .then(scheduleDeletion());
} }
if (ctx.message.reply_to_message) { if (ctx.message.reply_to_message) {
ctx.deleteMessage(ctx.message.reply_to_message.message_id) ctx.deleteMessage(ctx.message.reply_to_message.message_id).catch(
.catch(() => null); () => null
);
} }
return ctx.warn({ return ctx.warn({
admin: ctx.from, admin: ctx.from,
amend: flags.has('amend'), amend: flags.has('amend'),
reason: '[' + ctx.chat.title + '] ' + await substom(reason), reason: '[' + ctx.chat.title + '] ' + (await substom(reason)),
userToWarn, userToWarn,
mode: 'manual', mode: 'manual',
}); });

View File

@ -1,7 +1,7 @@
import { Context, Middleware } from "telegraf"; import { Context, Middleware } from 'telegraf';
import { addGroup } from "../../stores/group"; import { addGroup } from '../../stores/group';
import { admin } from "../../stores/user"; import { admin } from '../../stores/user';
import { isMaster } from "../../utils/config"; import { isMaster } from '../../utils/config';
const addedToGroupHandler: Middleware<Context> = async (ctx, next) => { const addedToGroupHandler: Middleware<Context> = async (ctx, next) => {
const wasAdded = ctx.message?.new_chat_members?.some( const wasAdded = ctx.message?.new_chat_members?.some(
@ -11,15 +11,15 @@ const addedToGroupHandler: Middleware<Context> = async (ctx, next) => {
await admin(ctx.from!); await admin(ctx.from!);
const link = ctx.chat!.username const link = ctx.chat!.username
? `https://t.me/${ctx.chat!.username.toLowerCase()}` ? `https://t.me/${ctx.chat!.username.toLowerCase()}`
: await ctx.exportChatInviteLink().catch(() => ""); : await ctx.exportChatInviteLink().catch(() => '');
if (!link) { if (!link) {
await ctx.replyWithHTML( await ctx.replyWithHTML(
"⚠️ <b>Failed to export chat invite link.</b>\n" + '⚠️ <b>Failed to export chat invite link.</b>\n' +
"Group won't be visible in /groups list.\n" + "Group won't be visible in /groups list.\n" +
"\n" + '\n' +
"If this isn't your intention, " + "If this isn't your intention, " +
"make sure I am permitted to export chat invite link, " + 'make sure I am permitted to export chat invite link, ' +
"and then run /showgroup." 'and then run /showgroup.'
); );
} }
const { id, title, type } = ctx.chat!; const { id, title, type } = ctx.chat!;

View File

@ -2,14 +2,15 @@
const { pMap } = require('../../utils/promise'); const { pMap } = require('../../utils/promise');
const link = user => '@' + user.username; const link = (user) => '@' + user.username;
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const antibotHandler = async (ctx, next) => { const antibotHandler = async (ctx, next) => {
const msg = ctx.message; const msg = ctx.message;
const bots = msg.new_chat_members.filter(user => const bots = msg.new_chat_members.filter(
user.is_bot && user.username !== ctx.me); (user) => user.is_bot && user.username !== ctx.me
);
if (bots.length === 0) { if (bots.length === 0) {
return next(); return next();
@ -19,11 +20,10 @@ const antibotHandler = async (ctx, next) => {
return next(); return next();
} }
await pMap(bots, bot => await pMap(bots, (bot) => ctx.banChatMember(bot.id));
ctx.kickChatMember(bot.id));
await ctx.replyWithHTML( await ctx.replyWithHTML(
`🚫 <b>Kicked bot(s):</b> ${bots.map(link).join(', ')}`, `🚫 <b>Kicked bot(s):</b> ${bots.map(link).join(', ')}`
); );
return next(); return next();

View File

@ -1,17 +1,17 @@
/* eslint new-cap: ["error", {"capIsNewExceptionPattern": "^(?:Action|jspack)\."}] */ /* eslint new-cap: ["error", {"capIsNewExceptionPattern": "^(?:Action|jspack)\."}] */
import * as R from "ramda"; import * as R from 'ramda';
import { html, lrm } from "../../utils/html"; import { html, lrm } from '../../utils/html';
import { isAdmin, permit } from "../../stores/user"; import { isAdmin, permit } from '../../stores/user';
import { config } from "../../utils/config"; import { config } from '../../utils/config';
import type { ExtendedContext } from "../../typings/context"; import type { ExtendedContext } from '../../typings/context';
import { jspack } from "jspack"; import { jspack } from 'jspack';
import { managesGroup } from "../../stores/group"; import { managesGroup } from '../../stores/group';
import type { MessageEntity } from "telegraf/typings/telegram-types"; import type { MessageEntity } from 'telegraf/typings/telegram-types';
import { pMap } from "../../utils/promise"; import { pMap } from '../../utils/promise';
import { telegram } from "../../bot"; import { telegram } from '../../bot';
import { URL } from "url"; import { URL } from 'url';
import XRegExp = require("xregexp"); import XRegExp = require('xregexp');
const { excludeLinks = [], blacklistedDomains = [] } = config; const { excludeLinks = [], blacklistedDomains = [] } = config;
const { fetch } = require('undici'); const { fetch } = require('undici');
@ -22,19 +22,19 @@ if (excludeLinks === false) {
return; return;
} }
const tmeDomains = ["t.me", "telega.one", "telegram.dog", "telegram.me"]; const tmeDomains = ['t.me', 'telega.one', 'telegram.dog', 'telegram.me'];
const tmeDomainRegex = XRegExp.union(tmeDomains); const tmeDomainRegex = XRegExp.union(tmeDomains);
const normalizeTme = R.replace( const normalizeTme = R.replace(
XRegExp.tag("i")`^(?:@|(?:https?:\/\/)?${tmeDomainRegex}\/)(\w+)(\/.+)?`, XRegExp.tag('i')`^(?:@|(?:https?:\/\/)?${tmeDomainRegex}\/)(\w+)(\/.+)?`,
(_match, username, rest) => (_match, username, rest) =>
/^\/\d+$/.test(rest) /^\/\d+$/.test(rest)
? `https://t.me/${username.toLowerCase()}` ? `https://t.me/${username.toLowerCase()}`
: `https://t.me/${username.toLowerCase()}${rest || ""}` : `https://t.me/${username.toLowerCase()}${rest || ''}`
); );
const stripQuery = (s: string) => s.split("?", 1)[0]; const stripQuery = (s: string) => s.split('?', 1)[0];
const customWhitelist = new Set(excludeLinks.map(normalizeTme).map(stripQuery)); const customWhitelist = new Set(excludeLinks.map(normalizeTme).map(stripQuery));
@ -65,35 +65,38 @@ type Action = Action.Nothing | Action.Notify | Action.Warn;
const actionPriority = (action: Action) => action.type; const actionPriority = (action: Action) => action.type;
const maxByActionPriority = R.maxBy(actionPriority); const maxByActionPriority = R.maxBy(actionPriority);
const highestPriorityAction = R.reduce(maxByActionPriority, Action.Nothing); const highestPriorityAction = (arr: Action[]) =>
arr.reduce(maxByActionPriority, Action.Nothing);
const assumeProtocol = R.unless(R.contains("://"), R.concat("http://")); const assumeProtocol = R.unless(R.includes('://'), (s) => `http://${s}`);
const isHttp = R.propSatisfies(R.test(/^https?:$/i), "protocol"); const isHttp = R.propSatisfies(R.test(/^https?:$/i), 'protocol');
const isLink = (entity: MessageEntity) => const isLink = (entity: MessageEntity) =>
["url", "text_link", "mention"].includes(entity.type); ['url', 'text_link', 'mention'].includes(entity.type);
const obtainUrlFromText = (text: string) => ({ length, offset, url = "" }) => const obtainUrlFromText =
url ? url : text.slice(offset, length + offset); (text: string) =>
({ length, offset, url = '' }) =>
url ? url : text.slice(offset, length + offset);
const blacklisted = { const blacklisted = {
protocol: (url: URL) => protocol: (url: URL) =>
url.protocol === "tg:" && url.host.toLowerCase() === "resolve", url.protocol === 'tg:' && url.host.toLowerCase() === 'resolve',
}; };
const isPublic = async (username: string) => { const isPublic = async (username: string) => {
try { try {
const chat = await telegram.getChat(username); const chat = await telegram.getChat(username);
return chat.type !== "private"; return chat.type !== 'private';
} catch (err) { } catch (err) {
return false; return false;
} }
}; };
const inviteLinkToGroupID = (url: URL) => { const inviteLinkToGroupID = (url: URL) => {
if (url.pathname.toLowerCase().startsWith("/joinchat/")) { if (url.pathname.toLowerCase().startsWith('/joinchat/')) {
const res = jspack.Unpack( const res = jspack.Unpack(
">LLQ", '>LLQ',
Buffer.from(url.pathname.split("/")[2], "base64") Buffer.from(url.pathname.split('/')[2], 'base64')
); );
if (Array.isArray(res)) { if (Array.isArray(res)) {
const [, groupID] = res; const [, groupID] = res;
@ -112,24 +115,26 @@ const managesLinkedGroup = (url: URL) => {
const dh = { const dh = {
blacklistedDomain: R.always( blacklistedDomain: R.always(
Promise.resolve(Action.Warn("Link to a blacklisted domain")) Promise.resolve(Action.Warn('Link to a blacklisted domain'))
), ),
nothing: R.always(Promise.resolve(Action.Nothing)), nothing: R.always(Promise.resolve(Action.Nothing)),
tme: async (url: URL) => { tme: async (url: URL) => {
if (url.pathname === "/") return Action.Nothing; if (url.pathname === '/') return Action.Nothing;
if (url.pathname.toLowerCase().startsWith("/c/")) return Action.Nothing; if (url.pathname.toLowerCase().startsWith('/c/')) return Action.Nothing;
if (url.pathname.toLowerCase().startsWith("/addtheme/")) return Action.Nothing; if (url.pathname.toLowerCase().startsWith('/addtheme/'))
if (url.pathname.toLowerCase().startsWith("/addstickers/")) { return Action.Nothing;
if (url.pathname.toLowerCase().startsWith('/addstickers/')) {
return Action.Nothing; return Action.Nothing;
} }
if (url.pathname.toLowerCase().startsWith("/setlanguage/")) { if (url.pathname.toLowerCase().startsWith('/setlanguage/')) {
return Action.Nothing; return Action.Nothing;
} }
if (url.searchParams.has("start")) return Action.Warn("Bot reflink"); if (url.searchParams.has('start')) return Action.Warn('Bot reflink');
if (await managesLinkedGroup(url)) return Action.Nothing; if (await managesLinkedGroup(url)) return Action.Nothing;
const [, username] = R.match(/^\/(\w+)(?:\/\d*)?$/, url.pathname); const [, username] = R.match(/^\/(\w+)(?:\/\d*)?$/, url.pathname);
if (username && !(await isPublic("@" + username))) return Action.Nothing; if (username && !(await isPublic('@' + username)))
return Action.Warn("Link to a Telegram group or channel"); return Action.Nothing;
return Action.Warn('Link to a Telegram group or channel');
}, },
}; };
@ -144,13 +149,14 @@ const isWhitelisted = (url: URL) =>
customWhitelist.has(stripQuery(url.toString())); customWhitelist.has(stripQuery(url.toString()));
class CodeError extends Error { class CodeError extends Error {
url?: URL;
constructor(readonly code: string) { constructor(readonly code: string) {
super(code); super(code);
} }
} }
const unshorten = (url: URL | string) => const unshorten = (url: URL | string) =>
fetch(url, { redirect: "follow" }).then((res) => fetch(url, { redirect: 'follow' }).then((res) =>
res.ok res.ok
? new URL(normalizeTme(res.url)) ? new URL(normalizeTme(res.url))
: Promise.reject(new CodeError(`${res.status} ${res.statusText}`)) : Promise.reject(new CodeError(`${res.status} ${res.statusText}`))
@ -162,43 +168,49 @@ const checkLinkByDomain = (url: URL) => {
return handler(url); return handler(url);
}; };
const classifyAsync = R.memoize(async (url: URL) => { const classifyAsync = R.memoizeWith(
if (isWhitelisted(url)) return Action.Nothing; (key) => key.href,
async (url: URL) => {
if (isWhitelisted(url)) return Action.Nothing;
if (blacklisted.protocol(url)) return Action.Warn("Link using tg protocol"); if (blacklisted.protocol(url))
return Action.Warn('Link using tg protocol');
if (domainHandlers.has(url.host.toLowerCase())) return checkLinkByDomain(url); if (domainHandlers.has(url.host.toLowerCase()))
return checkLinkByDomain(url);
if (!isHttp(url)) return Action.Nothing; if (!isHttp(url)) return Action.Nothing;
try { try {
const longUrl = await unshorten(url); const longUrl = await unshorten(url);
if (isWhitelisted(longUrl)) return Action.Nothing; if (isWhitelisted(longUrl)) return Action.Nothing;
return checkLinkByDomain(longUrl); return checkLinkByDomain(longUrl);
} catch (e) { } catch (_e) {
e.url = url; const e = _e as CodeError;
return Action.Notify(e); e.url = url;
return Action.Notify(e);
}
} }
}); );
const classifyList = (urls: URL[]) => const classifyList = (urls: URL[]) =>
pMap(urls, classifyAsync).then(highestPriorityAction); pMap(urls, classifyAsync).then(highestPriorityAction);
const matchTmeLinks = R.match(XRegExp.tag("gi")`\b${tmeDomainRegex}\/[\w-/]+`); const matchTmeLinks = R.match(XRegExp.tag('gi')`\b${tmeDomainRegex}\/[\w-/]+`);
const maybeProp = (prop) => (o) => (R.has(prop, o) ? [o[prop]] : []); const maybeProp = (prop) => (o) => R.has(prop, o) ? [o[prop]] : [];
const buttonUrls = R.pipe( const buttonUrls = R.pipe(
R.path(["reply_markup", "inline_keyboard"]), R.path(['reply_markup', 'inline_keyboard']),
R.defaultTo([]), R.defaultTo([]),
// @ts-ignore // @ts-ignore
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
R.unnest, R.unnest,
R.chain(maybeProp("url")) R.chain(maybeProp('url'))
); );
const classifyCtx = (ctx: ExtendedContext) => { const classifyCtx = (ctx: ExtendedContext) => {
if (!ctx.chat?.type.endsWith("group")) return Action.Nothing; if (!ctx.chat?.type.endsWith('group')) return Action.Nothing;
const message = ctx.message || ctx.editedMessage; const message = ctx.message || ctx.editedMessage;
@ -206,7 +218,7 @@ const classifyCtx = (ctx: ExtendedContext) => {
const entities = message.entities || message.caption_entities || []; const entities = message.entities || message.caption_entities || [];
const text = message.text || message.caption || ""; const text = message.text || message.caption || '';
const rawUrls = entities const rawUrls = entities
.filter(isLink) .filter(isLink)
@ -240,10 +252,10 @@ export = async (ctx: ExtendedContext, next) => {
ctx.deleteMessage().catch(() => null); ctx.deleteMessage().catch(() => null);
return ctx.warn({ return ctx.warn({
admin: ctx.botInfo!, admin: ctx.botInfo,
reason: action.reason, reason: action.reason,
userToWarn, userToWarn,
mode: "auto", mode: 'auto',
}); });
} }

View File

@ -15,7 +15,7 @@ module.exports = (ctx, next) => {
from: ctx.from, from: ctx.from,
chat: ctx.chat, chat: ctx.chat,
text: ctx.callbackQuery.data, text: ctx.callbackQuery.data,
entities: [ { offset: 0, type: 'bot_command' } ], entities: [{ offset: 0, type: 'bot_command' }],
}, },
}; };

View File

@ -37,17 +37,17 @@ composer.use(updateUserDataHandler);
composer.on('new_chat_members', syncStatusHandler, antibotHandler); composer.on('new_chat_members', syncStatusHandler, antibotHandler);
composer.on('message', kickBannedHandler); composer.on('message', kickBannedHandler);
composer.use(removeChannelForwardsHandler); composer.use(removeChannelForwardsHandler);
composer.on([ 'edited_message', 'message' ], checkLinksHandler); composer.on(['edited_message', 'message'], checkLinksHandler);
composer.on('new_chat_title', updateGroupTitleHandler); composer.on('new_chat_title', updateGroupTitleHandler);
composer.on('text', removeCommandsHandler); composer.on('text', removeCommandsHandler);
composer.on( composer.on(
[ 'new_chat_members', 'left_chat_member' ], ['new_chat_members', 'left_chat_member'],
deleteAfter(deleteJoinsAfter), deleteAfter(deleteJoinsAfter),
presenceLogHandler, presenceLogHandler
); );
composer.action( composer.action(
/^\/del -chat_id=(-\d+) -msg_id=(\d+) Report handled/, /^\/del -chat_id=(-\d+) -msg_id=(\d+) Report handled/,
reportHandled, reportHandled
); );
composer.on('callback_query', commandButtons); composer.on('callback_query', commandButtons);

View File

@ -9,8 +9,9 @@ const kickBannedHandler = (ctx, next) => {
} }
if (ctx.from.status === 'banned') { if (ctx.from.status === 'banned') {
ctx.deleteMessage().catch(noop); ctx.deleteMessage().catch(noop);
return ctx.kickChatMember(ctx.from.id) return ctx
.catch(err => ctx.reply(`⚠️ kickBanned: ${err}`)); .banChatMember(ctx.from.id)
.catch((err) => ctx.reply(`⚠️ kickBanned: ${err}`));
} }
return next(); return next();
}; };

View File

@ -6,7 +6,6 @@ const { chats = {} } = require('../../utils/config').config;
const pkg = require('../../package.json'); const pkg = require('../../package.json');
const caption = `\ const caption = `\
Sorry, you need to set up your own instance \ Sorry, you need to set up your own instance \
to use me in your group or a network of groups. to use me in your group or a network of groups.
@ -15,10 +14,14 @@ If you don't wish to self host, \
you can try @MissRose_bot instead. you can try @MissRose_bot instead.
`; `;
const inline_keyboard = [ [ { const inline_keyboard = [
text: '🛠 Setup a New Bot', [
url: pkg.homepage, {
} ] ]; text: '🛠 Setup a New Bot',
url: pkg.homepage,
},
],
];
const reply_markup = JSON.stringify({ inline_keyboard }); const reply_markup = JSON.stringify({ inline_keyboard });
@ -30,15 +33,13 @@ const gifIds = [
'3XiQswSmbjBiU', '3XiQswSmbjBiU',
]; ];
const gifs = gifIds.map(x => `https://media.giphy.com/media/${x}/giphy.gif`); const gifs = gifIds.map((x) => `https://media.giphy.com/media/${x}/giphy.gif`);
/** /**
* @template T * @template T
* @param {Array<T>} arr * @param {Array<T>} arr
*/ */
const randomChoice = arr => arr[Math.floor(Math.random() * arr.length)]; const randomChoice = (arr) => arr[Math.floor(Math.random() * arr.length)];
/** @param { import('telegraf').Context } ctx */ /** @param { import('telegraf').Context } ctx */
const leaveUnmanagedHandler = async (ctx, next) => { const leaveUnmanagedHandler = async (ctx, next) => {
@ -50,7 +51,8 @@ const leaveUnmanagedHandler = async (ctx, next) => {
if ( if (
ctx.chat.type === 'private' || ctx.chat.type === 'private' ||
Object.values(chats).includes(ctx.chat.id) || Object.values(chats).includes(ctx.chat.id) ||
await managesGroup({ id: ctx.chat.id })) { (await managesGroup({ id: ctx.chat.id }))
) {
return next(); return next();
} }

View File

@ -1,7 +1,9 @@
'use strict'; 'use strict';
const R = require('ramda'); const R = require('ramda');
const { Telegraf: { optional, passThru } } = require('telegraf'); const {
Telegraf: { optional, passThru },
} = require('telegraf');
const { permit } = require('../../stores/user'); const { permit } = require('../../stores/user');
const { html, lrm } = require('../../utils/html'); const { html, lrm } = require('../../utils/html');
@ -13,29 +15,29 @@ if (excludeLinks === false || excludeLinks === '*') {
} }
const isChannelForward = R.pathEq( const isChannelForward = R.pathEq(
[ 'message', 'forward_from_chat', 'type' ], ['message', 'forward_from_chat', 'type'],
'channel', 'channel'
); );
const fromAdmin = R.pathEq([ 'from', 'status' ], 'admin'); const fromAdmin = R.pathEq(['from', 'status'], 'admin');
const inGroup = ctx => ctx.chat?.type.endsWith('group'); const inGroup = (ctx) => ctx.chat?.type.endsWith('group');
const capturingGroups = R.tail; const capturingGroups = R.tail;
const toUsername = R.compose( const toUsername = R.compose(
capturingGroups, capturingGroups,
R.match(/^(?:@|(?:https?:\/\/)?(?:t\.me|telegram\.(?:me|dog))\/)(\w+)/i), R.match(/^(?:@|(?:https?:\/\/)?(?:t\.me|telegram\.(?:me|dog))\/)(\w+)/i)
); );
const customWhitelist = R.pipe( const customWhitelist = R.pipe(
R.chain(toUsername), R.chain(toUsername),
R.map(R.toLower), R.map(R.toLower),
R.constructN(1, Set), R.constructN(1, Set)
)(excludeLinks); )(excludeLinks);
const isWhitelisted = username => customWhitelist.has(username.toLowerCase()); const isWhitelisted = (username) => customWhitelist.has(username.toLowerCase());
const fromWhitelisted = ctx => const fromWhitelisted = (ctx) =>
isWhitelisted(ctx.message.forward_from_chat.username || ''); isWhitelisted(ctx.message.forward_from_chat.username || '');
const pred = R.allPass([ const pred = R.allPass([
@ -48,7 +50,9 @@ const pred = R.allPass([
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const handler = async (ctx, next) => { const handler = async (ctx, next) => {
if (await permit.revoke(ctx.from)) { if (await permit.revoke(ctx.from)) {
await ctx.replyWithHTML(html`${lrm}${ctx.from.first_name} used 🎟 permit!`); await ctx.replyWithHTML(
html`${lrm}${ctx.from.first_name} used 🎟 permit!`
);
return next(); return next();
} }

View File

@ -7,12 +7,13 @@ const { unmatched } = require('../unmatched');
const shouldDelete = { const shouldDelete = {
all: () => true, all: () => true,
none: () => false, none: () => false,
own: ctx => !ctx.state[unmatched], own: (ctx) => !ctx.state[unmatched],
}; };
if (!(deleteCommands in shouldDelete)) { if (!(deleteCommands in shouldDelete)) {
throw new Error('Invalid value for `deleteCommands` in config file: ' + throw new Error(
deleteCommands); 'Invalid value for `deleteCommands` in config file: ' + deleteCommands
);
} }
const noop = Function.prototype; const noop = Function.prototype;

View File

@ -1,11 +1,14 @@
import type { ExtendedContext } from "../../typings/context"; import type { ExtendedContext } from '../../typings/context';
export = (ctx: ExtendedContext) => { export = (ctx: ExtendedContext) => {
if (ctx.from?.status !== "admin") { if (ctx.from?.status !== 'admin') {
return ctx.answerCbQuery("✋ Not permitted!", false, { cache_time: 600 }); return ctx.answerCbQuery('✋ Not permitted!', {
show_alert: false,
cache_time: 600,
});
} }
const [, chatId, msgId] = ctx.match!; const [, chatId, msgId] = ctx.callbackQuery?.inline_message_id!;
return Promise.all([ return Promise.all([
ctx.deleteMessage(), ctx.deleteMessage(),

View File

@ -12,7 +12,7 @@ const { pMap } = require('../../utils/promise');
const handleNewMember = async (ctx, newMember) => { const handleNewMember = async (ctx, newMember) => {
if (await spamwatch.shouldKick(newMember)) { if (await spamwatch.shouldKick(newMember)) {
const until_date = (Date.now() + ms('24h')) / 1000; const until_date = (Date.now() + ms('24h')) / 1000;
return ctx.kickChatMember(newMember.id, until_date); return ctx.banChatMember(newMember.id, until_date);
} }
return null; return null;
@ -20,7 +20,7 @@ const handleNewMember = async (ctx, newMember) => {
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const syncStatusHandler = (ctx, next) => { const syncStatusHandler = (ctx, next) => {
pMap(ctx.message.new_chat_members, async newMember => { pMap(ctx.message.new_chat_members, async (newMember) => {
if (newMember.is_bot) { if (newMember.is_bot) {
return null; return null;
} }
@ -29,21 +29,21 @@ const syncStatusHandler = (ctx, next) => {
const { status = 'member' } = dbUser || {}; const { status = 'member' } = dbUser || {};
switch (status) { switch (status) {
case 'admin': case 'admin':
return ctx.promoteChatMember(newMember.id, { return ctx.promoteChatMember(newMember.id, {
can_change_info: false, can_change_info: false,
can_delete_messages: true, can_delete_messages: true,
can_invite_users: true, can_invite_users: true,
can_pin_messages: true, can_pin_messages: true,
can_promote_members: false, can_promote_members: false,
can_restrict_members: true, can_restrict_members: true,
}); });
case 'banned': case 'banned':
return ctx.kickChatMember(newMember.id); return ctx.banChatMember(newMember.id);
case 'member': case 'member':
return handleNewMember(ctx, newMember); return handleNewMember(ctx, newMember);
default: default:
throw new Error(`Unexpected member status: ${dbUser.status}`); throw new Error(`Unexpected member status: ${dbUser.status}`);
} }
}).catch(() => null); }).catch(() => null);

View File

@ -1,6 +1,8 @@
'use strict'; 'use strict';
const { Telegraf: { hears } } = require('telegraf'); const {
Telegraf: { hears },
} = require('telegraf');
const XRegExp = require('xregexp'); const XRegExp = require('xregexp');
const { managesGroup } = require.main.require('./stores/group'); const { managesGroup } = require.main.require('./stores/group');
@ -16,7 +18,7 @@ $`;
/** @param { import('../../typings/context').ExtendedContext } ctx */ /** @param { import('../../typings/context').ExtendedContext } ctx */
const handler = async (ctx, next) => { const handler = async (ctx, next) => {
let [ , groupName ] = ctx.match; let [, groupName] = ctx.match;
if (groupName.toLowerCase() === 'this') { if (groupName.toLowerCase() === 'this') {
if (!ctx.chat.title) return next(); if (!ctx.chat.title) return next();
groupName = ctx.chat.title; groupName = ctx.chat.title;

View File

@ -1,6 +1,8 @@
'use strict'; 'use strict';
const { Telegraf: { compose, hears } } = require('telegraf'); const {
Telegraf: { compose, hears },
} = require('telegraf');
/* eslint-disable global-require */ /* eslint-disable global-require */

View File

@ -1,6 +1,8 @@
'use strict'; 'use strict';
const { Telegraf: { hears } } = require('telegraf'); const {
Telegraf: { hears },
} = require('telegraf');
const R = require('ramda'); const R = require('ramda');
// DB // DB
@ -16,32 +18,30 @@ const deleteCustom = config.deleteCustom || { longerThan: Infinity };
const capitalize = R.replace(/^./, R.toUpper); const capitalize = R.replace(/^./, R.toUpper);
const getRepliedToId = R.path([ 'reply_to_message', 'message_id' ]); const getRepliedToId = R.path(['reply_to_message', 'message_id']);
const typeToMethod = type => const typeToMethod = (type) =>
type === 'text' type === 'text' ? 'replyWithHTML' : `replyWith${capitalize(type)}`;
? 'replyWithHTML'
: `replyWith${capitalize(type)}`;
const autoDelete = ({ content, type }) => { const autoDelete = ({ content, type }) => {
switch (type) { switch (type) {
case 'text': case 'text':
return content.length > deleteCustom.longerThan; return content.length > deleteCustom.longerThan;
case 'copy': case 'copy':
return (content.text || '').length > deleteCustom.longerThan; return (content.text || '').length > deleteCustom.longerThan;
default: default:
return false; return false;
} }
}; };
const hasRole = (role, from) => { const hasRole = (role, from) => {
switch (role.toLowerCase()) { switch (role.toLowerCase()) {
case 'master': case 'master':
return isMaster(from); return isMaster(from);
case 'admins': case 'admins':
return from && from.status === 'admin'; return from && from.status === 'admin';
default: default:
return true; return true;
} }
}; };
@ -57,17 +57,17 @@ const runCustomCmdHandler = async (ctx, next) => {
const { caption, content, type } = command; const { caption, content, type } = command;
const options = { const options = {
...caption && { caption }, ...(caption && { caption }),
disable_web_page_preview: true, disable_web_page_preview: true,
reply_to_message_id: getRepliedToId(ctx.message), reply_to_message_id: getRepliedToId(ctx.message),
}; };
return ctx[typeToMethod(type)](content, options) return ctx[typeToMethod(type)](content, options).then(({ message_id }) =>
.then(({ message_id }) => scheduleDeletion(autoDelete(command) && deleteCustom.after)({
scheduleDeletion( chat: ctx.chat,
autoDelete(command) && deleteCustom.after)({ message_id,
chat: ctx.chat, message_id })
})); );
}; };
module.exports = hears(/^! ?(\w+)/, runCustomCmdHandler); module.exports = hears(/^! ?(\w+)/, runCustomCmdHandler);

View File

@ -1,11 +1,11 @@
'use strict'; 'use strict';
/** @param { import('../typings/context').ExtendedContext } ctx */ /** @param { import('../typings/context').ExtendedContext } ctx */
const unmatchedHandler = async ctx => { const unmatchedHandler = async (ctx) => {
ctx.state[unmatchedHandler.unmatched] = true; ctx.state[unmatchedHandler.unmatched] = true;
if (ctx.chat && ctx.chat.type === 'private') { if (ctx.chat && ctx.chat.type === 'private') {
await ctx.reply( await ctx.reply(
'Sorry, I couldn\'t understand that, do you need /help?', "Sorry, I couldn't understand that, do you need /help?"
); );
} }
}; };

View File

@ -18,7 +18,7 @@ bot.use(
require('./plugins'), require('./plugins'),
require('./handlers/commands'), require('./handlers/commands'),
require('./handlers/regex'), require('./handlers/regex'),
require('./handlers/unmatched'), require('./handlers/unmatched')
); );
bot.catch(logError); bot.catch(logError);

7836
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,10 @@
"scripts": { "scripts": {
"postversion": "git push --atomic --follow-tags origin develop develop:master", "postversion": "git push --atomic --follow-tags origin develop develop:master",
"start": "node index", "start": "node index",
"lint": "eslint --ext .ts --ext .js .", "lint": "eslint --ext .ts --ext .js . --fix",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"fmt": "prettier --write .",
"check": "npm run -s typecheck && npm run -s lint && npm run -s fmt"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -37,29 +39,33 @@
"jspack": "0.0.4", "jspack": "0.0.4",
"millisecond": "^0.1.2", "millisecond": "^0.1.2",
"nedb-promise": "^2.0.1", "nedb-promise": "^2.0.1",
"ramda": "^0.25.0", "ramda": "^0.28.0",
"require-directory": "^2.1.1", "require-directory": "^2.1.1",
"spamwatch": "^0.4.0", "spamwatch": "^0.4.0",
"string-replace-async": "^2.0.0", "string-replace-async": "^2.0.0",
"telegraf": "^4.8.3", "telegraf": "^4.12.1",
"ts-node": "^10.7.0", "ts-node": "^10.9.1",
"typescript": "^4.6.3", "typescript": "^4.9.5",
"undici": "^4.16.0", "undici": "^5.20.0",
"xregexp": "^5.1.0" "xregexp": "^5.1.1"
}, },
"engines": { "engines": {
"node": ">=12.20.2" "node": ">=12.20.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^13.13.2", "@types/node": "^13.13.2",
"@types/node-fetch": "^2.5.7", "@types/ramda": "^0.28.23",
"@types/ramda": "^0.25.51",
"@types/xregexp": "^4.3.0", "@types/xregexp": "^4.3.0",
"@typescript-eslint/eslint-plugin": "^2.31.0", "@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^2.31.0", "@typescript-eslint/parser": "^5.54.1",
"eslint": "^6.8.0", "eslint": "^8.35.0",
"eslint-config-prettier": "^6.11.0", "eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^3.1.3", "eslint-plugin-prettier": "^4.2.1",
"prettier": "2.0.5" "prettier": "2.8.4"
},
"prettier": {
"useTabs": true,
"singleQuote": true,
"tabWidth": 4
} }
} }

View File

@ -1,10 +1,9 @@
# Plugins # # Plugins
Plugins let you extend the bot with new functionality Plugins let you extend the bot with new functionality
without touching source code of the bot. without touching source code of the bot.
## Using plugins
## Using plugins ##
To use a plugin, put it in this directory and add it's name To use a plugin, put it in this directory and add it's name
to `plugins` array in `config.js`. to `plugins` array in `config.js`.
@ -13,8 +12,7 @@ If `plugins` is undefined, no plugins are loaded.
However, this behavior may change, don't rely on it. However, this behavior may change, don't rely on it.
If you want no plugins to be loaded, explicitly set it to empty array. If you want no plugins to be loaded, explicitly set it to empty array.
## Creating a plugin
## Creating a plugin ##
Plugin is basically "requirable" Plugin is basically "requirable"
(JS file, or directory with `index.js`) (JS file, or directory with `index.js`)
@ -23,13 +21,11 @@ which exports a valid Telegraf handler
Plugins are similar to [micro-bot] bots. Plugins are similar to [micro-bot] bots.
## Known plugins
## Known plugins ## - [Captcha](https://gist.github.com/poeti8/d84dfc4538510366a2d89294ff52b4ae): Adds a simple captcha to the bot to kick spam bots on join.
- [Banfiles](https://gist.github.com/poeti8/133796200d66049c9bd58e6265a52f68) Ban users that send files with specified extentions.
* [Captcha](https://gist.github.com/poeti8/d84dfc4538510366a2d89294ff52b4ae): Adds a simple captcha to the bot to kick spam bots on join. - [Anti Arabic](https://gist.github.com/poeti8/966ccef35d61ad2735dc0120ce3e8760) Bans users that send an Arabic/Persian message.
* [Banfiles](https://gist.github.com/poeti8/133796200d66049c9bd58e6265a52f68) Ban users that send files with specified extentions. - [Anti X-POST](https://gist.github.com/poeti8/c3057f973466676ca8dbbb1183cd0624) Removes same messages sent by user across one or multiple groups.
* [Anti Arabic](https://gist.github.com/poeti8/966ccef35d61ad2735dc0120ce3e8760) Bans users that send an Arabic/Persian message.
* [Anti X-POST](https://gist.github.com/poeti8/c3057f973466676ca8dbbb1183cd0624) Removes same messages sent by user across one or multiple groups.
[micro-bot]: https://github.com/telegraf/micro-bot [micro-bot]: https://github.com/telegraf/micro-bot

View File

@ -1,10 +1,12 @@
'use strict'; 'use strict';
const { Telegraf: { compose } } = require('telegraf'); const {
Telegraf: { compose },
} = require('telegraf');
const { config } = require('../utils/config'); const { config } = require('../utils/config');
const names = config.plugins || []; const names = config.plugins || [];
const plugins = names.map(name => `./${name}`).map(require); const plugins = names.map((name) => `./${name}`).map(require);
module.exports = compose(plugins); module.exports = compose(plugins);

View File

@ -12,7 +12,7 @@ Command.ensureIndex({
unique: true, unique: true,
}); });
const addCommand = command => const addCommand = (command) =>
Command.update( Command.update(
{ name: command.name }, { name: command.name },
{ $set: { isActive: false, ...command } }, { $set: { isActive: false, ...command } },
@ -22,7 +22,7 @@ const addCommand = command =>
const updateCommand = (data) => const updateCommand = (data) =>
Command.update({ id: data.id, isActive: false }, { $set: data }); Command.update({ id: data.id, isActive: false }, { $set: data });
const removeCommand = command => Command.remove(command); const removeCommand = (command) => Command.remove(command);
const getCommand = (data) => Command.findOne(data); const getCommand = (data) => Command.findOne(data);

View File

@ -12,32 +12,26 @@ Group.ensureIndex({
unique: true, unique: true,
}); });
const addGroup = group => const addGroup = (group) =>
Group.update({ id: group.id }, group, { upsert: true }); Group.update({ id: group.id }, group, { upsert: true });
const hideGroup = ({ id }) => const hideGroup = ({ id }) => Group.update({ id }, { $set: { link: '' } });
Group.update({ id }, { $set: { link: '' } });
const updateGroup = group => const updateGroup = (group) => Group.update({ id: group.id }, { $set: group });
Group.update({ id: group.id }, { $set: group });
const listGroups = (query = {}) => const listGroups = (query = {}) => Group.find(query);
Group.find(query);
const listVisibleGroups = () => const listVisibleGroups = () => Group.find({ $not: { link: '' } });
Group.find({ $not: { link: '' } });
const managesGroup = group => const managesGroup = (group) => Group.findOne(group);
Group.findOne(group);
const migrateGroup = (oldId, newId) => const migrateGroup = (oldId, newId) =>
Group.update( Group.update(
{ id: oldId, type: 'group' }, { id: oldId, type: 'group' },
{ $set: { id: newId, type: 'supergroup' } }, { $set: { id: newId, type: 'supergroup' } }
); );
const removeGroup = ({ id }) => const removeGroup = ({ id }) => Group.remove({ id });
Group.remove({ id });
module.exports = { module.exports = {
addGroup, addGroup,

View File

@ -3,7 +3,7 @@
/** /**
* @typedef { { id: number } | { username: string } } UserQuery * @typedef { { id: number } | { username: string } } UserQuery
* @exports UserQuery * @exports UserQuery
*/ */
// Utils // Utils
const { strip } = require('../utils/cmd'); const { strip } = require('../utils/cmd');
@ -31,27 +31,25 @@ User.ensureIndex({
User.update( User.update(
{ username: '' }, { username: '' },
{ $unset: { username: true } }, { $unset: { username: true } },
{ multi: true }, { multi: true }
).then(() => ).then(() => User.ensureIndex({ fieldName: 'username', sparse: true }));
User.ensureIndex({ fieldName: 'username', sparse: true }));
const normalizeTgUser = R.pipe( const normalizeTgUser = R.pipe(
R.pick([ 'first_name', 'id', 'last_name', 'username' ]), R.pick(['first_name', 'id', 'last_name', 'username']),
R.evolve({ username: R.toLower }), R.evolve({ username: R.toLower }),
R.merge({ first_name: '', last_name: '' }), R.mergeDeepRight({ first_name: '', last_name: '' })
); );
const getUpdatedDocument = R.prop(1); const getUpdatedDocument = R.prop(1);
const getUser = user => const getUser = (user) => User.findOne(user);
User.findOne(user);
const updateUser = async (rawTgUser) => { const updateUser = async (rawTgUser) => {
const tgUser = normalizeTgUser(rawTgUser); const tgUser = normalizeTgUser(rawTgUser);
const { id, username } = tgUser; const { id, username } = tgUser;
const [ rawDbUser ] = await Promise.all([ const [rawDbUser] = await Promise.all([
getUser({ id }), getUser({ id }),
User.update({ $not: { id }, username }, { $unset: { username: true } }), User.update({ $not: { id }, username }, { $unset: { username: true } }),
]); ]);
@ -60,7 +58,7 @@ const updateUser = async (rawTgUser) => {
return User.update( return User.update(
{ id }, { id },
{ status: 'member', warns: [], ...tgUser }, { status: 'member', warns: [], ...tgUser },
{ returnUpdatedDocs: true, upsert: true }, { returnUpdatedDocs: true, upsert: true }
).then(getUpdatedDocument); ).then(getUpdatedDocument);
} }
@ -70,7 +68,7 @@ const updateUser = async (rawTgUser) => {
return User.update( return User.update(
{ id }, { id },
{ $set: tgUser }, { $set: tgUser },
{ returnUpdatedDocs: true }, { returnUpdatedDocs: true }
).then(getUpdatedDocument); ).then(getUpdatedDocument);
} }
@ -78,16 +76,11 @@ const updateUser = async (rawTgUser) => {
}; };
const admin = ({ id }) => const admin = ({ id }) =>
User.update( User.update({ id }, { $set: { status: 'admin', warns: [] } });
{ id },
{ $set: { status: 'admin', warns: [] } },
);
const getAdmins = () => const getAdmins = () => User.find({ status: 'admin' });
User.find({ status: 'admin' });
const unadmin = ({ id }) => const unadmin = ({ id }) => User.update({ id }, { $set: { status: 'member' } });
User.update({ id }, { $set: { status: 'member' } });
const isAdmin = (user) => { const isAdmin = (user) => {
if (!user) return false; if (!user) return false;
@ -101,14 +94,14 @@ const ban = ({ id }, ban_details) =>
User.update( User.update(
{ id, $not: { status: 'admin' } }, { id, $not: { status: 'admin' } },
{ $set: { ban_details, status: 'banned' } }, { $set: { ban_details, status: 'banned' } },
{ upsert: true }, { upsert: true }
); );
const batchBan = (users, ban_details) => const batchBan = (users, ban_details) =>
User.update( User.update(
{ $or: users.map(strip), $not: { status: 'admin' } }, { $or: users.map(strip), $not: { status: 'admin' } },
{ $set: { ban_details, status: 'banned' } }, { $set: { ban_details, status: 'banned' } },
{ multi: true, returnUpdatedDocs: true }, { multi: true, returnUpdatedDocs: true }
).then(getUpdatedDocument); ).then(getUpdatedDocument);
const ensureExists = ({ id }) => const ensureExists = ({ id }) =>
@ -120,7 +113,7 @@ const unban = ({ id }) =>
{ {
$set: { status: 'member' }, $set: { status: 'member' },
$unset: { ban_details: true, ban_reason: true }, $unset: { ban_details: true, ban_reason: true },
}, }
); );
/** /**
@ -130,7 +123,7 @@ const permit = (user, { by_id, date }) =>
User.update( User.update(
user, user,
{ $set: { permit: { by_id, date } } }, { $set: { permit: { by_id, date } } },
{ returnUpdatedDocs: true }, { returnUpdatedDocs: true }
).then(getUpdatedDocument); ).then(getUpdatedDocument);
/** /**
@ -140,7 +133,7 @@ permit.revoke = (user) =>
User.update( User.update(
{ permit: { $exists: true }, ...strip(user) }, { permit: { $exists: true }, ...strip(user) },
{ $unset: { permit: true } }, { $unset: { permit: true } },
{ returnUpdatedDocs: true }, { returnUpdatedDocs: true }
).then(getUpdatedDocument); ).then(getUpdatedDocument);
permit.isValid = (p) => Date.now() - ms('24h') < p?.date; permit.isValid = (p) => Date.now() - ms('24h') < p?.date;
@ -153,7 +146,7 @@ const warn = ({ id }, reason, { amend }) =>
$push: { warns: reason }, $push: { warns: reason },
$unset: { permit: true }, $unset: { permit: true },
}, },
{ returnUpdatedDocs: true }, { returnUpdatedDocs: true }
).then(getUpdatedDocument); ).then(getUpdatedDocument);
const unwarn = ({ id }, warnQuery) => const unwarn = ({ id }, warnQuery) =>
@ -163,10 +156,10 @@ const unwarn = ({ id }, warnQuery) =>
$pull: { warns: warnQuery }, $pull: { warns: warnQuery },
$set: { status: 'member' }, $set: { status: 'member' },
$unset: { ban_details: true, ban_reason: true }, $unset: { ban_details: true, ban_reason: true },
}, }
); );
const nowarns = query => unwarn(query, {}); const nowarns = (query) => unwarn(query, {});
const verifyCaptcha = ({ id }, captcha = true) => const verifyCaptcha = ({ id }, captcha = true) =>
User.update({ id }, { $set: { captcha } }); User.update({ id }, { $set: { captcha } });

View File

@ -5,6 +5,7 @@
"noEmit": true, "noEmit": true,
"noImplicitAny": false, "noImplicitAny": false,
"strict": true, "strict": true,
"target": "ES2019" "target": "ES2019",
"skipLibCheck": true
} }
} }

6
typings/config.d.ts vendored
View File

@ -1,6 +1,6 @@
import type { InlineKeyboardMarkup } from "telegraf/typings/telegram-types"; import type { InlineKeyboardMarkup } from 'telegraf/types';
export type InlineKeyboard = InlineKeyboardMarkup["inline_keyboard"]; export type InlineKeyboard = InlineKeyboardMarkup['inline_keyboard'];
/** /**
* String to be parsed by https://npmjs.com/millisecond, * String to be parsed by https://npmjs.com/millisecond,
@ -44,7 +44,7 @@ export interface Config {
* Which messages with commands should be deleted? * Which messages with commands should be deleted?
* Defaults to 'own' -- don't delete commands meant for other bots. * Defaults to 'own' -- don't delete commands meant for other bots.
*/ */
deleteCommands?: "all" | "own" | "none"; deleteCommands?: 'all' | 'own' | 'none';
deleteCustom?: { deleteCustom?: {
longerThan: number; // UTF-16 characters longerThan: number; // UTF-16 characters

20
typings/context.d.ts vendored
View File

@ -1,13 +1,9 @@
import type { import type { Convenience, Message, User } from 'telegraf/types';
ExtraReplyMessage, import type { Context } from 'telegraf';
Message, import type { TgHtml } from '../utils/html';
User,
} from "telegraf/typings/telegram-types";
import type { Context } from "telegraf";
import type { TgHtml } from "../utils/html";
interface DbUser { interface DbUser {
status: "member" | "admin" | "banned"; status: 'member' | 'admin' | 'banned';
} }
export interface ContextExtensions { export interface ContextExtensions {
@ -34,24 +30,24 @@ export interface ContextExtensions {
amend?: boolean; amend?: boolean;
reason: string; reason: string;
userToWarn: User; userToWarn: User;
mode: "auto" | "manual"; mode: 'auto' | 'manual';
} }
): Promise<Message>; ): Promise<Message>;
loggedReply( loggedReply(
this: ExtendedContext, this: ExtendedContext,
html: TgHtml, html: TgHtml,
extra?: ExtraReplyMessage extra?: Convenience.ExtraReplyMessage
): Promise<Message>; ): Promise<Message>;
replyWithHTML( replyWithHTML(
this: void, this: void,
html: string | TgHtml, html: string | TgHtml,
extra?: ExtraReplyMessage extra?: Convenience.ExtraReplyMessage
): Promise<Message>; ): Promise<Message>;
replyWithCopy( replyWithCopy(
this: ExtendedContext, this: ExtendedContext,
content: Message, content: Message,
options?: ExtraReplyMessage options?: Convenience.ExtraReplyMessage
): Promise<Message>; ): Promise<Message>;
} }

View File

@ -1,19 +1,25 @@
import XRegExp = require("xregexp"); import * as XRegExp from 'xregexp';
import type { Message, MessageEntity } from "telegraf/typings/telegram-types"; import type { Message, MessageEntity } from 'telegraf/types';
export const strip = ({ id, username }) => export const strip = ({ id, username }) =>
id ? { id } : { username: username.toLowerCase() }; id ? { id } : { username: username.toLowerCase() };
const toUserObject = (s) => const toUserObject = (s) =>
s.user || (/^\d+$/.test(s) ? { id: +s } : { username: s.replace("@", "") }); s.user || (/^\d+$/.test(s) ? { id: +s } : { username: s.replace('@', '') });
const isTextMention = (m: MessageEntity) => m.type === "text_mention"; const isTextMention = (m: MessageEntity) => m.type === 'text_mention';
const spliceOut = (s: string, { offset, length }: MessageEntity) => const spliceOut = (s: string, { offset, length }: MessageEntity) =>
s.slice(0, offset) + s.slice(offset + length); s.slice(0, offset) + s.slice(offset + length);
const botReply = ({ from, entities = [] }: Message) => { const botReply = (message: Message) => {
const textMentions = entities.filter(isTextMention); const { from } = message;
const textMentions =
'entities' in message && typeof message.entities !== 'undefined'
? (message.entities.filter(
isTextMention
) as MessageEntity.TextMentionMessageEntity[])
: [];
return from?.is_bot && textMentions.length === 1 && [textMentions[0].user]; return from?.is_bot && textMentions.length === 1 && [textMentions[0].user];
}; };
@ -27,7 +33,7 @@ function* extractFlags(flagS: string) {
} }
} }
const regex = XRegExp.tag("snx")`^ const regex = XRegExp.tag('snx')`^
\/\w+(@\w+)? \/\w+(@\w+)?
(?<flagS> ${flagsRegex}*) (?<flagS> ${flagsRegex}*)
(?<ids> (\s+@\w+|\s+\d+)*) (?<ids> (\s+@\w+|\s+\d+)*)
@ -36,25 +42,29 @@ const regex = XRegExp.tag("snx")`^
$`; $`;
export const isCommand = ( export const isCommand = (
message?: Message message?: Message.TextMessage
): message is Message & { ): message is Message.TextMessage & {
text: string; text: string;
entities: [{ type: "bot_command"; offset: 0 }]; entities: [{ type: 'bot_command'; offset: 0 }];
} => { } => {
const firstEntity = message?.entities?.[0]; const firstEntity = message?.entities?.[0];
return firstEntity?.type === "bot_command" && firstEntity.offset === 0; return firstEntity?.type === 'bot_command' && firstEntity.offset === 0;
}; };
export const parse = (message?: Message) => { export const parse = (message?: Message.TextMessage) => {
if (!isCommand(message)) { if (!isCommand(message)) {
throw new TypeError("Not a command"); throw new TypeError('Not a command');
} }
const textMentions = message.entities.filter(isTextMention); const textMentions = message.entities.filter(isTextMention);
const noTextMentions = textMentions.reduceRight(spliceOut, message.text); const noTextMentions = textMentions.reduceRight(spliceOut, message.text);
const { flagS, ids, reason = "" } = XRegExp.exec(noTextMentions, regex)?.groups!; const {
flagS,
ids,
reason = '',
} = XRegExp.exec(noTextMentions, regex)?.groups!;
const flags = new Map(extractFlags(flagS)); const flags = new Map(extractFlags(flagS));
const users = textMentions.concat(ids.match(/@\w+|\d+/g) || []); const users = [...textMentions, ...(ids.match(/@\w+|\d+/g) || [])];
const { reply_to_message } = message; const { reply_to_message } = message;
// prettier-ignore // prettier-ignore

View File

@ -1,24 +1,21 @@
'use strict'; 'use strict';
const camelToSnake = s => s.replace(/[A-Z]/g, c => '_' + c.toLowerCase()); const camelToSnake = (s) => s.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
exports.stringify = ({ command = '', flags = {}, reason = '' }) => { exports.stringify = ({ command = '', flags = {}, reason = '' }) => {
const flagS = Object.entries(flags) const flagS = Object.entries(flags)
.flatMap(([ key, value ]) => { .flatMap(([key, value]) => {
switch (value) { switch (value) {
case null: case null:
case false: case false:
case undefined: // eslint-disable-line no-undefined case undefined: // eslint-disable-line no-undefined
return []; return [];
case true: case true:
return [ '-' + camelToSnake(key) ]; return ['-' + camelToSnake(key)];
default: default:
return [ `-${camelToSnake(key)}=${value}` ]; return [`-${camelToSnake(key)}=${value}`];
} }
}).join(' '); })
return [ .join(' ');
'/' + command, return ['/' + command, flagS, reason].filter(Boolean).join(' ');
flagS,
reason
].filter(Boolean).join(' ');
}; };

View File

@ -1,15 +1,15 @@
import replace = require("string-replace-async"); import replace = require('string-replace-async');
import { getCommand } from "../../stores/command"; import { getCommand } from '../../stores/command';
export const substom = (reason: string): Promise<string> => export const substom = (reason: string): Promise<string> =>
replace(reason, /!\s?(\w+)\s*|.+/g, async (match, name) => { replace(reason, /!\s?(\w+)\s*|.+/g, async (match, name) => {
if (!name) return match; if (!name) return match;
const command = await getCommand({ const command = await getCommand({
name: name.toLowerCase(), name: name.toLowerCase(),
role: { $ne: "master" }, role: { $ne: 'master' },
type: "copy", type: 'copy',
}); });
const text = command?.content.text || command?.content.caption; const text = command?.content.text || command?.content.caption;
if (!text) return match; if (!text) return match;
return text + " "; return text + ' ';
}); });

View File

@ -10,26 +10,30 @@ const ms = require('millisecond');
const { expireWarnsAfter = Infinity } = config; const { expireWarnsAfter = Infinity } = config;
const isNewerThan = date => warning => warning.date >= date; const isNewerThan = (date) => (warning) => warning.date >= date;
/** @param {Date} date */ /** @param {Date} date */
const isWarnNotExpired = date => const isWarnNotExpired = (date) =>
isNewerThan(date.getTime() - ms(expireWarnsAfter)); isNewerThan(date.getTime() - ms(expireWarnsAfter));
const stringOrNumber = x => [ 'string', 'number' ].includes(typeof x); const stringOrNumber = (x) => ['string', 'number'].includes(typeof x);
// @ts-ignore // @ts-ignore
const masters = [].concat(config.master); const masters = [].concat(config.master);
if (!masters.every(x => stringOrNumber(x) && /^@?\w+$/.test(x))) { if (!masters.every((x) => stringOrNumber(x) && /^@?\w+$/.test(x))) {
throw new Error('Invalid value for `master` in config file: ' + throw new Error(
config.master); 'Invalid value for `master` in config file: ' + config.master
);
} }
const isMaster = user => const isMaster = (user) =>
user && masters.some(x => user &&
user.id === Number(x) || masters.some(
user.username && eq.username(user.username, String(x))); (x) =>
user.id === Number(x) ||
(user.username && eq.username(user.username, String(x)))
);
module.exports = { module.exports = {
config, config,

View File

@ -1,24 +1,25 @@
'use strict';
// from https://gist.github.com/f9e184b78bbfc4419bc1ee70c238ca6f // from https://gist.github.com/f9e184b78bbfc4419bc1ee70c238ca6f
import dedent = require("dedent-js"); import dedent = require('dedent-js');
const symbol = Symbol("TgHtml.symbol"); const symbol = Symbol('TgHtml.symbol');
type Escapable = bigint | boolean | Error | number | string; type Escapable = bigint | boolean | Error | number | string;
type Sub = Escapable | TgHtml; type Sub = Escapable | TgHtml;
const escapeHtml = (s: Escapable) => const escapeHtml = (s: Escapable) =>
String(s) String(s)
.replace(/&/g, "&amp;") .replace(/&/g, '&amp;')
.replace(/"/g, "&quot;") .replace(/"/g, '&quot;')
.replace(/'/g, "&#39;") .replace(/'/g, '&#39;')
.replace(/</g, "&lt;"); .replace(/</g, '&lt;');
const isTgHtml = (o: Sub): o is TgHtml => typeof o?.[symbol] === "string"; const isTgHtml = (o: Sub): o is TgHtml => typeof o?.[symbol] === 'string';
const toHtml = (o: Sub) => (isTgHtml(o) ? o[symbol] : escapeHtml(o)); const toHtml = (o: Sub) => (isTgHtml(o) ? o[symbol] : escapeHtml(o));
export class TgHtml { export class TgHtml {
readonly [Symbol.toStringTag] = "TgHtml"; readonly [Symbol.toStringTag] = 'TgHtml';
private readonly [symbol]: string; private readonly [symbol]: string;
private constructor(s: string) { private constructor(s: string) {
this[symbol] = s; this[symbol] = s;
@ -37,7 +38,7 @@ export class TgHtml {
return new TgHtml(s.map(toHtml).join(toHtml(sep))); return new TgHtml(s.map(toHtml).join(toHtml(sep)));
} }
static concat(...s: Sub[]) { static concat(...s: Sub[]) {
return TgHtml.join("", s); return TgHtml.join('', s);
} }
static pre(s: Sub) { static pre(s: Sub) {
return TgHtml.tag`<pre>${s}</pre>`; return TgHtml.tag`<pre>${s}</pre>`;
@ -47,4 +48,4 @@ export class TgHtml {
export const html = (raw: TemplateStringsArray, ...subs: Sub[]) => export const html = (raw: TemplateStringsArray, ...subs: Sub[]) =>
TgHtml.tag(raw, ...subs); TgHtml.tag(raw, ...subs);
export const lrm = "\u200E"; export const lrm = '\u200E';

View File

@ -5,12 +5,12 @@
const { inspect } = require('util'); const { inspect } = require('util');
const logError = err => console.error(err); const logError = (err) => console.error(err);
const print = value => const print = (value) =>
console.log(inspect(value, { colors: true, depth: null })); console.log(inspect(value, { colors: true, depth: null }));
module.exports = { module.exports = {
logError, logError,
print print,
}; };

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const username = s => s.replace(/^@/, '').toLowerCase(); const username = (s) => s.replace(/^@/, '').toLowerCase();
module.exports = { module.exports = {
username, username,

View File

@ -13,4 +13,4 @@ exports.shouldKick = (function () {
const client = new Client(config.spamwatch.token, config.spamwatch.host); const client = new Client(config.spamwatch.token, config.spamwatch.host);
return ({ id }) => client.getBan(id); return ({ id }) => client.getBan(id);
}()); })();

View File

@ -7,31 +7,31 @@ const { telegram } = require('../bot');
const { html, lrm } = require('./html'); const { html, lrm } = require('./html');
const R = require('ramda'); const R = require('ramda');
const replyId = R.path([ 'reply_to_message', 'message_id' ]); const replyId = R.path(['reply_to_message', 'message_id']);
const { isCommand } = require('../utils/cmd'); const { isCommand } = require('../utils/cmd');
const inlineKeyboard = (...inline_keyboard) => const inlineKeyboard = (...inline_keyboard) => ({
({ reply_markup: { inline_keyboard } }); reply_markup: { inline_keyboard },
});
const msgLink = msg => const msgLink = (msg) =>
`https://t.me/c/${msg.chat.id.toString().slice(4)}/${msg.message_id}`; `https://t.me/c/${msg.chat.id.toString().slice(4)}/${msg.message_id}`;
const link = ({ id, first_name }) => const link = ({ id, first_name }) =>
html`${lrm}<a href="tg://user?id=${id}">${first_name}</a> [<code>${id}</code>]`; html`${lrm}<a href="tg://user?id=${id}">${first_name}</a>
[<code>${id}</code>]`;
const quietLink = (user) => const quietLink = (user) =>
user.username user.username
? html`<a href="t.me/${user.username}">${user.first_name}</a>` ? html`<a href="t.me/${user.username}">${user.first_name}</a>`
: html`<a href="tg://user?id=${user.id}">${user.first_name}</a>`; : html`<a href="tg://user?id=${user.id}">${user.first_name}</a>`;
const displayUser = user => const displayUser = (user) =>
user.first_name user.first_name ? link(user) : html`[<code>${user.id}</code>]`;
? link(user)
: html`[<code>${user.id}</code>]`;
/** @param {number | string | false} ms */ /** @param {number | string | false} ms */
const deleteAfter = ms => (ctx, next) => { const deleteAfter = (ms) => (ctx, next) => {
if (ms !== false) { if (ms !== false) {
setTimeout(ctx.deleteMessage.bind(ctx), millisecond(ms)); setTimeout(ctx.deleteMessage.bind(ctx), millisecond(ms));
} }
@ -39,18 +39,20 @@ const deleteAfter = ms => (ctx, next) => {
}; };
/** @param {number | string | false} ms */ /** @param {number | string | false} ms */
const scheduleDeletion = (ms = 5 * 60 * 1000) => message => { const scheduleDeletion =
const { chat, message_id } = message; (ms = 5 * 60 * 1000) =>
(message) => {
const { chat, message_id } = message;
if (chat.type !== 'private' && ms !== false) { if (chat.type !== 'private' && ms !== false) {
message.timeout = setTimeout( message.timeout = setTimeout(
() => telegram.deleteMessage(chat.id, message_id), () => telegram.deleteMessage(chat.id, message_id),
millisecond(ms), millisecond(ms)
); );
} }
return message; return message;
}; };
module.exports = { module.exports = {
deleteAfter, deleteAfter,