2
0
mirror of https://github.com/thedevs-network/the-guard-bot synced 2025-08-22 01:49:29 +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
jobs:
build:
docker:
- image: circleci/node:12
build:
docker:
- image: circleci/node:12
working_directory: ~/repo
working_directory: ~/repo
steps:
- checkout
steps:
- checkout
- restore_cache:
keys:
- v2-dependencies-{{ checksum "package-lock.json" }}
- v2-dependencies-
- restore_cache:
keys:
- v2-dependencies-{{ checksum "package-lock.json" }}
- v2-dependencies-
- run: npm ci
- run: npm ci
- save_cache:
paths:
- "$HOME/.npm"
key: v2-dependencies-{{ checksum "package-lock.json" }}
- save_cache:
paths:
- '$HOME/.npm'
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/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"prettier/@typescript-eslint"
"plugin:prettier/recommended"
],
"rules": {
"@typescript-eslint/ban-ts-ignore": "warn",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@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": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-mixed-spaces-and-tabs": "off",
"linebreak-style": ["error", "unix"],
"semi": ["error", "always"],
"@typescript-eslint/no-base-to-string": "error",
"@typescript-eslint/no-floating-promises": "error",
"no-console": [ "error", { "allow": [ "assert" ] } ],
"no-console": ["error", { "allow": ["assert"] }],
"for-direction": "error",
"no-await-in-loop": "error",
"no-extra-parens": "error",
"no-template-curly-in-string": "error",
"accessor-pairs": "error",
"array-callback-return": "error",
"block-scoped-var": "error",
"class-methods-use-this": "error",
"consistent-return": "error",
"curly": [
"error",
"multi-line"
],
"default-case": "error",
"dot-location": [
"error",
"property"
],
"dot-location": ["error", "property"],
"dot-notation": "error",
"eqeqeq": "error",
"no-alert": "error",
@ -123,17 +102,12 @@
],
"require-await": "error",
"vars-on-top": "error",
"strict": [
"error",
"global"
],
"no-catch-shadow": "error",
"strict": ["error", "global"],
"no-label-var": "error",
"no-shadow": "error",
"no-shadow-restricted-names": "error",
"no-undef-init": "error",
"no-undefined": "error",
"no-use-before-define": "error",
"@typescript-eslint/no-use-before-define": "error",
"global-require": "error",
"handle-callback-err": "error",
"no-buffer-constructor": "error",
@ -141,26 +115,16 @@
"no-new-require": "error",
"no-path-concat": "error",
"no-process-exit": "error",
"array-bracket-spacing": [
"error",
"always"
],
"block-spacing": "error",
"brace-style": "error",
"comma-spacing": "error",
"comma-style": "error",
"computed-property-spacing": "error",
"consistent-this": [
"error",
"self"
],
"consistent-this": ["error", "self"],
"eol-last": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": [
"error",
"as-needed"
],
"func-names": ["error", "as-needed"],
"func-style": [
"error",
"declaration",
@ -168,12 +132,9 @@
"allowArrowFunctions": true
}
],
"function-paren-newline": ["error", "multiline-arguments"],
"comma-dangle": ["warn", "always-multiline"],
"key-spacing": "error",
"keyword-spacing": "error",
"lines-around-comment": "error",
"max-len": ["error", {"ignoreTemplateLiterals": true}],
"max-len": ["error", { "ignoreTemplateLiterals": true }],
"max-nested-callbacks": "error",
"max-params": "error",
"max-statements": "off",
@ -195,10 +156,7 @@
"no-underscore-dangle": "off",
"no-unneeded-ternary": "error",
"no-whitespace-before-property": "error",
"object-curly-spacing": [
"error",
"always"
],
"object-curly-spacing": ["error", "always"],
"object-property-newline": [
"error",
{
@ -206,7 +164,6 @@
}
],
"operator-assignment": "error",
"operator-linebreak": "error",
"quote-props": [
"error",
"as-needed",
@ -215,15 +172,6 @@
"numbers": false
}
],
"require-jsdoc": [
"off",
{
"require": {
"MethodDefinition": true,
"ClassDeclaration": true
}
}
],
"semi-spacing": "error",
"semi-style": "error",
"sort-vars": [

View File

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

View File

@ -3,7 +3,6 @@
"editor.rulers": []
},
"eslint.validate": ["javascript", "typescript"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
"editor.formatOnSave": 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**
## Table of Contents
* [Key Features](#key-features)
* [Setup](#setup)
* [Commands](#commands)
* [Plugins](#plugins)
* [Support](#support)
* [License](#license)
- [Key Features](#key-features)
- [Setup](#setup)
- [Commands](#commands)
- [Plugins](#plugins)
- [Support](#support)
- [License](#license)
## Key Features
* Synchronized across multiple groups.
* Adding admins to the bot.
* Auto-remove and warn channels and groups ads.
* Kick bots added by users.
* Warn and ban users to control the group.
* Commands work with replying, mentioning and ID.
* Removes commands and temporary bot messages.
* Ability to create custom commands.
* Supports plugins.
- Synchronized across multiple groups.
- Adding admins to the bot.
- Auto-remove and warn channels and groups ads.
- Kick bots added by users.
- Warn and ban users to control the group.
- Commands work with replying, mentioning and ID.
- Removes commands and temporary bot messages.
- Ability to create custom commands.
- Supports plugins.
Overall, keeps the groups clean and healthy to use.
## Setup
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**.
@ -40,6 +43,7 @@ You need [Node.js](https://nodejs.org/) (>= 12) to run this bot.
5. Start the bot via `npm start`.
### 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.
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.
## Commands
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.
`/leave <name\|id>` | _Master_ | _Everywhere_ | Make the bot to leave the group cleanly.
`/hidegroup` | _Master_ | _Groups_ | Revoke invite link and hide the group from `/groups` list.
`/showgroup` | _Master_ | _Groups_ | Make the group accessible via `/groups` list.
`/del [reason]` | _Admin_ | _Everywhere_ | Deletes replied-to message.
`/warn <reason>` | _Admin_ | _Groups_ | Warns the user.
`/unwarn` | _Admin_ | _Everywhere_ | Removes the last warn from the user.
`/nowarns` | _Admin_ | _Everywhere_ | Clears warns for the user.
`/permit` | _Admin_ | _Everywhere_ | Permits the user to advertise once, within 24 hours.
`/ban <reason>` | _Admin_ | _Groups_ | Bans the user from groups.
`/unban` | _Admin_ | _Everywhere_ | Removes the user from ban list.
`/user` | _Admin_ | _Everywhere_ | Shows the status of the user.
`/addcommand <name>` | _Admin_ | _In-Bot_ | Create a custom command.
`/removecommand <name>` | _Admin_ | _In-Bot_ | Remove a custom command.
`/staff` | _Everyone_ | _Everywhere_ | Shows a list of admins.
`/link` | _Everyone_ | _Everywhere_ | Shows the current group's link.
`/groups` | _Everyone_ | _Everywhere_ | Shows a list of groups which the bot is admin in.
`/report` | _Everyone_ | _Everywhere_ | Reports the replied-to message to admins.
`/commands` | _Everyone_ | _In-Bot_ | Shows a list of available commands.
`/help` \| `/start` | _Everyone_ | _In-Bot_ | How to use the bot.
| 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. |
| `/leave <name\|id>` | _Master_ | _Everywhere_ | Make the bot to leave the group cleanly. |
| `/hidegroup` | _Master_ | _Groups_ | Revoke invite link and hide the group from `/groups` list. |
| `/showgroup` | _Master_ | _Groups_ | Make the group accessible via `/groups` list. |
| `/del [reason]` | _Admin_ | _Everywhere_ | Deletes replied-to message. |
| `/warn <reason>` | _Admin_ | _Groups_ | Warns the user. |
| `/unwarn` | _Admin_ | _Everywhere_ | Removes the last warn from the user. |
| `/nowarns` | _Admin_ | _Everywhere_ | Clears warns for the user. |
| `/permit` | _Admin_ | _Everywhere_ | Permits the user to advertise once, within 24 hours. |
| `/ban <reason>` | _Admin_ | _Groups_ | Bans the user from groups. |
| `/unban` | _Admin_ | _Everywhere_ | Removes the user from ban list. |
| `/user` | _Admin_ | _Everywhere_ | Shows the status of the user. |
| `/addcommand <name>` | _Admin_ | _In-Bot_ | Create a custom command. |
| `/removecommand <name>` | _Admin_ | _In-Bot_ | Remove a custom command. |
| `/staff` | _Everyone_ | _Everywhere_ | Shows a list of admins. |
| `/link` | _Everyone_ | _Everywhere_ | Shows the current group's link. |
| `/groups` | _Everyone_ | _Everywhere_ | Shows a list of groups which the bot is admin in. |
| `/report` | _Everyone_ | _Everywhere_ | Reports the replied-to message to admins. |
| `/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.
@ -82,14 +87,14 @@ If used by reply, `/ban` and `/warn` would remove the replied-to message.
## 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.
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**:
* [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.
* [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.
- [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.
- [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.
## Support

View File

@ -16,8 +16,9 @@ module.exports = async ({ admin, reason, userToBan }) => {
await ban(userToBan, { by_id, date, reason });
await pMap(await listVisibleGroups(), group =>
telegram.kickChatMember(group.id, userToBan.id));
await pMap(await listVisibleGroups(), (group) =>
telegram.banChatMember(group.id, userToBan.id)
);
return html`
🚫 ${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');
/** @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} */
const cmp = (a, b) => Math.sign(a - b);
@ -26,7 +27,7 @@ module.exports = async ({ admin, amend, reason, userToWarn }) => {
const { warns } = await warn(
userToWarn,
{ by_id, date, reason },
{ amend },
{ amend }
);
const recentWarns = warns.filter(isWarnNotExpired(date));
@ -35,13 +36,21 @@ module.exports = async ({ admin, amend, reason, userToWarn }) => {
'-1': html`<b>${recentWarns.length}</b>/${numberOfWarnsToBan}`,
0: html`<b>Final warning</b>`,
// 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)];
const expiryText =
typeof expireWarnsAfter === 'undefined' || expireWarnsAfter === Infinity
? ''
: `Expires on ${yyyymmdd(
new Date(date.getTime() + ms(expireWarnsAfter))
)}`;
const warnMessage = html`
${lrm}${admin.first_name} <b>warned</b> ${link(userToWarn)}.
${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) {

View File

@ -12,9 +12,10 @@ const {
deleteBansAfter = false,
} = require('../utils/config').config;
const normalisedDeleteWarnsAfter = typeof deleteWarnsAfter === 'object'
? { auto: false, manual: false, ...deleteWarnsAfter }
: { auto: deleteWarnsAfter, manual: deleteWarnsAfter };
const normalisedDeleteWarnsAfter =
typeof deleteWarnsAfter === 'object'
? { auto: false, manual: false, ...deleteWarnsAfter }
: { auto: deleteWarnsAfter, manual: deleteWarnsAfter };
const reply_markup = { inline_keyboard: warnInlineKeyboard };
@ -22,27 +23,30 @@ const reply_markup = { inline_keyboard: warnInlineKeyboard };
module.exports = {
async ban({ admin, reason, userToBan }) {
const banMessage = await ban({ admin, reason, userToBan });
return this.loggedReply(banMessage)
.then(scheduleDeletion(deleteBansAfter));
return this.loggedReply(banMessage).then(
scheduleDeletion(deleteBansAfter)
);
},
async batchBan({ admin, reason, targets }) {
const banMessage = await batchBan({ admin, reason, targets });
return this.loggedReply(banMessage)
.then(scheduleDeletion(deleteBansAfter));
return this.loggedReply(banMessage).then(
scheduleDeletion(deleteBansAfter)
);
},
async warn({ admin, amend, reason, userToWarn, mode }) {
const warnMessage = await warn({ admin, amend, reason, userToWarn });
return this.loggedReply(warnMessage, { reply_markup })
.then(scheduleDeletion(normalisedDeleteWarnsAfter[mode]));
return this.loggedReply(warnMessage, { reply_markup }).then(
scheduleDeletion(normalisedDeleteWarnsAfter[mode])
);
},
loggedReply(html, extra) {
if (chats.adminLog) {
this.tg
this.telegram
.sendMessage(
chats.adminLog,
html.toJSON().replace(/\[<code>(\d+)<\/code>\]/g, '[#u$1]'),
{ parse_mode: 'HTML' },
{ parse_mode: 'HTML' }
)
.catch(() => null);
}
@ -50,6 +54,54 @@ module.exports = {
},
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;
// Otherwise the bot can't ban on reaching max warns due to botInfo not being available to get its admin ID
Object.defineProperty(bot.context, "botInfo", {
get () { return bot.botInfo; }
})
// Otherwise the bot can't ban on reaching max warns due to botInfo not being
// available to get its admin ID
Object.defineProperty(bot.context, 'botInfo', {
get() {
return bot.botInfo;
},
});
// cyclic dependency
// bot/index requires context requires actions/warn requires bot/index

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,19 +17,22 @@ module.exports = async (ctx) => {
if (!(flags.has('msg_id') || ctx.message.reply_to_message)) {
// 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;
}
await ctx.telegram.deleteMessage(
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) {
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: '🗑' }) : '🗑';
await ctx.replyWithHTML(html`${emoji} ${reason}`)
await ctx
.replyWithHTML(html`${emoji} ${reason}`)
.then(scheduleDeletion());
}
};

View File

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

View File

@ -25,7 +25,7 @@ const helpHandler = (ctx) => {
return ctx.replyWithHTML(
message,
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 rename = R.toLower;
const extensions = [ 'js', 'ts' ];
const extensions = ['js', 'ts'];
const handlers = requireDir(module, { exclude, extensions, rename });
router.handlers = new Map(Object.entries(handlers));

View File

@ -1,12 +1,14 @@
import { managesGroup, removeGroup } from "../../stores/group";
import { ExtendedContext } from "../../typings/context";
import { html } from "../../utils/html";
import { isMaster } from "../../utils/config";
import { managesGroup, removeGroup } from '../../stores/group';
import { ExtendedContext } from '../../typings/context';
import { html } from '../../utils/html';
import { isMaster } from '../../utils/config';
const leaveCommandHandler = async (ctx: ExtendedContext) => {
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
? await managesGroup(
@ -14,7 +16,7 @@ const leaveCommandHandler = async (ctx: ExtendedContext) => {
)
: ctx.chat;
if (!group) {
return ctx.replyWithHTML("❓ <b>Unknown group.</b>");
return ctx.replyWithHTML('❓ <b>Unknown group.</b>');
}
await removeGroup(group);

View File

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

View File

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

View File

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

View File

@ -12,35 +12,33 @@ const removeCommandHandler = async (ctx) => {
if (ctx.from.status !== 'admin') {
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) {
return ctx.replyWithHTML(
'<b>Send a valid command.</b>\n\nExample:\n' +
'<code>/removecommand rules</code>',
'<code>/removecommand rules</code>'
);
}
const command = await getCommand({ name: commandName.toLowerCase() });
if (!command) {
return ctx.replyWithHTML(
' <b>Command couldn\'t be found.</b>',
);
return ctx.replyWithHTML(" <b>Command couldn't be found.</b>");
}
const role = command.role.toLowerCase();
if (role === 'master' && !isMaster(ctx.from)) {
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() });
return ctx.replyWithHTML(
`✅ <code>!${commandName}</code> ` +
'<b>has been removed successfully.</b>',
'<b>has been removed successfully.</b>'
);
};

View File

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

View File

@ -7,8 +7,9 @@ const { isCommand } = require('../../utils/tg');
module.exports = ({ me, message }) => {
if (!isCommand(message)) return null;
const [ , command, username ] =
/^\/(?:start )?(\w+)(@\w+)?/.exec(message.text);
const [, command, username] = /^\/(?:start )?(\w+)(@\w+)?/.exec(
message.text
);
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');
/** @param { import('../../typings/context').ExtendedContext } ctx */
const staffHandler = async ctx => {
const staffHandler = async (ctx) => {
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 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}`, {
disable_notification: true,
disable_web_page_preview: true,
}).then(scheduleDeletion());
return ctx
.replyWithHTML(
html`<b>Admins in the network:</b>
${list}`,
{
disable_notification: true,
disable_web_page_preview: true,
}
)
.then(scheduleDeletion());
};
module.exports = staffHandler;

View File

@ -17,14 +17,16 @@ const noop = Function.prototype;
const tgUnadmin = async (userToUnadmin) => {
for (const group of await listGroups()) {
telegram.promoteChatMember(group.id, userToUnadmin.id, {
can_change_info: false,
can_delete_messages: false,
can_invite_users: false,
can_pin_messages: false,
can_promote_members: false,
can_restrict_members: false,
}).catch(noop);
telegram
.promoteChatMember(group.id, userToUnadmin.id, {
can_change_info: false,
can_delete_messages: false,
can_invite_users: false,
can_pin_messages: false,
can_promote_members: false,
can_restrict_members: false,
})
.catch(noop);
}
};
@ -35,22 +37,22 @@ const unAdminHandler = async (ctx) => {
const { targets } = parse(ctx.message);
if (targets.length !== 1) {
return ctx.replyWithHTML(
' <b>Specify one user to unadmin.</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML(' <b>Specify one user to unadmin.</b>')
.then(scheduleDeletion());
}
const userToUnadmin = await getUser(strip(targets[0]));
if (!userToUnadmin) {
return ctx.replyWithHTML(
'❓ <b>User unknown.</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML('❓ <b>User unknown.</b>')
.then(scheduleDeletion());
}
if (userToUnadmin.status !== 'admin') {
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);
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 */
const unbanHandler = async (ctx) => {
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);
if (targets.length !== 1) {
return ctx.replyWithHTML(
' <b>Specify one user to unban.</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML(' <b>Specify one user to unban.</b>')
.then(scheduleDeletion());
}
const userToUnban = await getUser(strip(targets[0]));
if (!userToUnban) {
return ctx.replyWithHTML(
'❓ <b>User unknown.</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML('❓ <b>User unknown.</b>')
.then(scheduleDeletion());
}
if (userToUnban.status !== 'banned') {
return ctx.replyWithHTML(' <b>User is not banned.</b>');
}
await pMap(await listGroups({ type: 'supergroup' }), (group) =>
ctx.telegram.unbanChatMember(group.id, userToUnban.id));
ctx.telegram.unbanChatMember(group.id, userToUnban.id)
);
await unban(userToUnban);
ctx.telegram.sendMessage(
userToUnban.id,
'♻️ You were unbanned from all of the /groups!',
).catch(() => null);
ctx.telegram
.sendMessage(
userToUnban.id,
'♻️ You were unbanned from all of the /groups!'
)
.catch(() => null);
// it's likely that the banned person haven't PMed the bot,
// which will cause the sendMessage to fail,
// hance .catch(noop)
// (it's an expected, non-critical failure)
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 */
const unwarnHandler = async (ctx) => {
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);
if (targets.length !== 1) {
return ctx.replyWithHTML(
' <b>Specify one user to unwarn.</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML(' <b>Specify one user to unwarn.</b>')
.then(scheduleDeletion());
}
const userToUnwarn = await getUser(strip(targets[0]));
if (!userToUnwarn) {
return ctx.replyWithHTML(
'❓ <b>User unknown</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML('❓ <b>User unknown</b>')
.then(scheduleDeletion());
}
const allWarns = userToUnwarn.warns.filter(isWarnNotExpired(new Date()));
if (allWarns.length === 0) {
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') {
await pMap(await listGroups({ type: 'supergroup' }), group =>
ctx.telegram.unbanChatMember(group.id, userToUnwarn.id));
await pMap(await listGroups({ type: 'supergroup' }), (group) =>
ctx.telegram.unbanChatMember(group.id, userToUnwarn.id)
);
}
let lastWarn;
@ -67,27 +70,30 @@ const unwarnHandler = async (ctx) => {
lastWarn = last(allWarns);
} else if (dateRegex.test(reason)) {
const normalized = reason.replace(' ', 'T').toUpperCase();
lastWarn = allWarns.find(({ date }) =>
date && date.toISOString().startsWith(normalized));
lastWarn = allWarns.find(
({ date }) => date && date.toISOString().startsWith(normalized)
);
} else {
return ctx.replyWithHTML(
'⚠ <b>Invalid date</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML('⚠ <b>Invalid date</b>')
.then(scheduleDeletion());
}
if (!lastWarn) {
return ctx.replyWithHTML(
'❓ <b>404: Warn not found</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML('❓ <b>404: Warn not found</b>')
.then(scheduleDeletion());
}
await unwarn(userToUnwarn, lastWarn);
if (userToUnwarn.status === 'banned') {
ctx.telegram.sendMessage(
userToUnwarn.id,
'♻️ You were unbanned from all of the /groups!',
).catch(() => null);
ctx.telegram
.sendMessage(
userToUnwarn.id,
'♻️ You were unbanned from all of the /groups!'
)
.catch(() => null);
// it's likely that the banned person haven't PMed the bot,
// which will cause the sendMessage to fail,
// hance .catch(noop)
@ -97,10 +103,9 @@ const unwarnHandler = async (ctx) => {
const count = html`<b>${allWarns.length}</b>/${numberOfWarnsToBan}`;
return ctx.loggedReply(html`
${lrm}${ctx.from.first_name} <b>pardoned</b> ${link(userToUnwarn)} for
${count}: ${lrm}${lastWarn.reason || lastWarn}
${lrm}${ctx.from.first_name} <b>pardoned</b> ${link(userToUnwarn)}
for ${count}: ${lrm}${lastWarn.reason || lastWarn}
`);
};
module.exports = unwarnHandler;

View File

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

View File

@ -11,7 +11,7 @@ const { getUser } = require('../../stores/user');
const warnHandler = async (ctx) => {
if (!ctx.message.chat.type.endsWith('group')) {
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);
if (targets.length !== 1) {
return ctx.replyWithHTML(
' <b>Specify one user to warn.</b>',
).then(scheduleDeletion());
return ctx
.replyWithHTML(' <b>Specify one user to warn.</b>')
.then(scheduleDeletion());
}
const userToWarn = await getUser(strip(targets[0]));
if (!userToWarn) {
return ctx.replyWithHTML(
'❓ <b>User unknown.</b>\n' +
'Please forward their message, then try again.',
).then(scheduleDeletion());
return ctx
.replyWithHTML(
'❓ <b>User unknown.</b>\n' +
'Please forward their message, then try again.'
)
.then(scheduleDeletion());
}
if (userToWarn.id === ctx.botInfo.id) return null;
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) {
return ctx.replyWithHTML(' <b>Need a reason to warn.</b>')
return ctx
.replyWithHTML(' <b>Need a reason to warn.</b>')
.then(scheduleDeletion());
}
if (ctx.message.reply_to_message) {
ctx.deleteMessage(ctx.message.reply_to_message.message_id)
.catch(() => null);
ctx.deleteMessage(ctx.message.reply_to_message.message_id).catch(
() => null
);
}
return ctx.warn({
admin: ctx.from,
amend: flags.has('amend'),
reason: '[' + ctx.chat.title + '] ' + await substom(reason),
reason: '[' + ctx.chat.title + '] ' + (await substom(reason)),
userToWarn,
mode: 'manual',
});

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ module.exports = (ctx, next) => {
from: ctx.from,
chat: ctx.chat,
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('message', kickBannedHandler);
composer.use(removeChannelForwardsHandler);
composer.on([ 'edited_message', 'message' ], checkLinksHandler);
composer.on(['edited_message', 'message'], checkLinksHandler);
composer.on('new_chat_title', updateGroupTitleHandler);
composer.on('text', removeCommandsHandler);
composer.on(
[ 'new_chat_members', 'left_chat_member' ],
['new_chat_members', 'left_chat_member'],
deleteAfter(deleteJoinsAfter),
presenceLogHandler,
presenceLogHandler
);
composer.action(
/^\/del -chat_id=(-\d+) -msg_id=(\d+) Report handled/,
reportHandled,
reportHandled
);
composer.on('callback_query', commandButtons);

View File

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

View File

@ -6,7 +6,6 @@ const { chats = {} } = require('../../utils/config').config;
const pkg = require('../../package.json');
const caption = `\
Sorry, you need to set up your own instance \
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.
`;
const inline_keyboard = [ [ {
text: '🛠 Setup a New Bot',
url: pkg.homepage,
} ] ];
const inline_keyboard = [
[
{
text: '🛠 Setup a New Bot',
url: pkg.homepage,
},
],
];
const reply_markup = JSON.stringify({ inline_keyboard });
@ -30,15 +33,13 @@ const gifIds = [
'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
* @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 */
const leaveUnmanagedHandler = async (ctx, next) => {
@ -50,7 +51,8 @@ const leaveUnmanagedHandler = async (ctx, next) => {
if (
ctx.chat.type === 'private' ||
Object.values(chats).includes(ctx.chat.id) ||
await managesGroup({ id: ctx.chat.id })) {
(await managesGroup({ id: ctx.chat.id }))
) {
return next();
}

View File

@ -1,7 +1,9 @@
'use strict';
const R = require('ramda');
const { Telegraf: { optional, passThru } } = require('telegraf');
const {
Telegraf: { optional, passThru },
} = require('telegraf');
const { permit } = require('../../stores/user');
const { html, lrm } = require('../../utils/html');
@ -13,29 +15,29 @@ if (excludeLinks === false || excludeLinks === '*') {
}
const isChannelForward = R.pathEq(
[ 'message', 'forward_from_chat', 'type' ],
'channel',
['message', 'forward_from_chat', 'type'],
'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 toUsername = R.compose(
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(
R.chain(toUsername),
R.map(R.toLower),
R.constructN(1, Set),
R.constructN(1, Set)
)(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 || '');
const pred = R.allPass([
@ -48,7 +50,9 @@ const pred = R.allPass([
/** @param { import('../../typings/context').ExtendedContext } ctx */
const handler = async (ctx, next) => {
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();
}

View File

@ -7,12 +7,13 @@ const { unmatched } = require('../unmatched');
const shouldDelete = {
all: () => true,
none: () => false,
own: ctx => !ctx.state[unmatched],
own: (ctx) => !ctx.state[unmatched],
};
if (!(deleteCommands in shouldDelete)) {
throw new Error('Invalid value for `deleteCommands` in config file: ' +
deleteCommands);
throw new Error(
'Invalid value for `deleteCommands` in config file: ' + deleteCommands
);
}
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) => {
if (ctx.from?.status !== "admin") {
return ctx.answerCbQuery("✋ Not permitted!", false, { cache_time: 600 });
if (ctx.from?.status !== 'admin') {
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([
ctx.deleteMessage(),

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
'use strict';
/** @param { import('../typings/context').ExtendedContext } ctx */
const unmatchedHandler = async ctx => {
const unmatchedHandler = async (ctx) => {
ctx.state[unmatchedHandler.unmatched] = true;
if (ctx.chat && ctx.chat.type === 'private') {
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('./handlers/commands'),
require('./handlers/regex'),
require('./handlers/unmatched'),
require('./handlers/unmatched')
);
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": {
"postversion": "git push --atomic --follow-tags origin develop develop:master",
"start": "node index",
"lint": "eslint --ext .ts --ext .js .",
"typecheck": "tsc --noEmit"
"lint": "eslint --ext .ts --ext .js . --fix",
"typecheck": "tsc --noEmit",
"fmt": "prettier --write .",
"check": "npm run -s typecheck && npm run -s lint && npm run -s fmt"
},
"repository": {
"type": "git",
@ -37,29 +39,33 @@
"jspack": "0.0.4",
"millisecond": "^0.1.2",
"nedb-promise": "^2.0.1",
"ramda": "^0.25.0",
"ramda": "^0.28.0",
"require-directory": "^2.1.1",
"spamwatch": "^0.4.0",
"string-replace-async": "^2.0.0",
"telegraf": "^4.8.3",
"ts-node": "^10.7.0",
"typescript": "^4.6.3",
"undici": "^4.16.0",
"xregexp": "^5.1.0"
"telegraf": "^4.12.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.5",
"undici": "^5.20.0",
"xregexp": "^5.1.1"
},
"engines": {
"node": ">=12.20.2"
},
"devDependencies": {
"@types/node": "^13.13.2",
"@types/node-fetch": "^2.5.7",
"@types/ramda": "^0.25.51",
"@types/ramda": "^0.28.23",
"@types/xregexp": "^4.3.0",
"@typescript-eslint/eslint-plugin": "^2.31.0",
"@typescript-eslint/parser": "^2.31.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"prettier": "2.0.5"
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^4.2.1",
"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
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 `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.
If you want no plugins to be loaded, explicitly set it to empty array.
## Creating a plugin ##
## Creating a plugin
Plugin is basically "requirable"
(JS file, or directory with `index.js`)
@ -23,13 +21,11 @@ which exports a valid Telegraf handler
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.
* [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.
- [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.
- [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

View File

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

View File

@ -12,7 +12,7 @@ Command.ensureIndex({
unique: true,
});
const addCommand = command =>
const addCommand = (command) =>
Command.update(
{ name: command.name },
{ $set: { isActive: false, ...command } },
@ -22,7 +22,7 @@ const addCommand = command =>
const updateCommand = (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);

View File

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

View File

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

View File

@ -5,6 +5,7 @@
"noEmit": true,
"noImplicitAny": false,
"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,
@ -44,7 +44,7 @@ export interface Config {
* Which messages with commands should be deleted?
* Defaults to 'own' -- don't delete commands meant for other bots.
*/
deleteCommands?: "all" | "own" | "none";
deleteCommands?: 'all' | 'own' | 'none';
deleteCustom?: {
longerThan: number; // UTF-16 characters

20
typings/context.d.ts vendored
View File

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

View File

@ -1,19 +1,25 @@
import XRegExp = require("xregexp");
import type { Message, MessageEntity } from "telegraf/typings/telegram-types";
import * as XRegExp from 'xregexp';
import type { Message, MessageEntity } from 'telegraf/types';
export const strip = ({ id, username }) =>
id ? { id } : { username: username.toLowerCase() };
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) =>
s.slice(0, offset) + s.slice(offset + length);
const botReply = ({ from, entities = [] }: Message) => {
const textMentions = entities.filter(isTextMention);
const botReply = (message: Message) => {
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];
};
@ -27,7 +33,7 @@ function* extractFlags(flagS: string) {
}
}
const regex = XRegExp.tag("snx")`^
const regex = XRegExp.tag('snx')`^
\/\w+(@\w+)?
(?<flagS> ${flagsRegex}*)
(?<ids> (\s+@\w+|\s+\d+)*)
@ -36,25 +42,29 @@ const regex = XRegExp.tag("snx")`^
$`;
export const isCommand = (
message?: Message
): message is Message & {
message?: Message.TextMessage
): message is Message.TextMessage & {
text: string;
entities: [{ type: "bot_command"; offset: 0 }];
entities: [{ type: 'bot_command'; offset: 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)) {
throw new TypeError("Not a command");
throw new TypeError('Not a command');
}
const textMentions = message.entities.filter(isTextMention);
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 users = textMentions.concat(ids.match(/@\w+|\d+/g) || []);
const users = [...textMentions, ...(ids.match(/@\w+|\d+/g) || [])];
const { reply_to_message } = message;
// prettier-ignore

View File

@ -1,24 +1,21 @@
'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 = '' }) => {
const flagS = Object.entries(flags)
.flatMap(([ key, value ]) => {
.flatMap(([key, value]) => {
switch (value) {
case null:
case false:
case undefined: // eslint-disable-line no-undefined
return [];
case true:
return [ '-' + camelToSnake(key) ];
default:
return [ `-${camelToSnake(key)}=${value}` ];
case null:
case false:
case undefined: // eslint-disable-line no-undefined
return [];
case true:
return ['-' + camelToSnake(key)];
default:
return [`-${camelToSnake(key)}=${value}`];
}
}).join(' ');
return [
'/' + command,
flagS,
reason
].filter(Boolean).join(' ');
})
.join(' ');
return ['/' + command, flagS, reason].filter(Boolean).join(' ');
};

View File

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

View File

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

View File

@ -1,24 +1,25 @@
'use strict';
// 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 Sub = Escapable | TgHtml;
const escapeHtml = (s: Escapable) =>
String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;");
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.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));
export class TgHtml {
readonly [Symbol.toStringTag] = "TgHtml";
readonly [Symbol.toStringTag] = 'TgHtml';
private readonly [symbol]: string;
private constructor(s: string) {
this[symbol] = s;
@ -37,7 +38,7 @@ export class TgHtml {
return new TgHtml(s.map(toHtml).join(toHtml(sep)));
}
static concat(...s: Sub[]) {
return TgHtml.join("", s);
return TgHtml.join('', s);
}
static pre(s: Sub) {
return TgHtml.tag`<pre>${s}</pre>`;
@ -47,4 +48,4 @@ export class TgHtml {
export const html = (raw: TemplateStringsArray, ...subs: Sub[]) =>
TgHtml.tag(raw, ...subs);
export const lrm = "\u200E";
export const lrm = '\u200E';

View File

@ -5,12 +5,12 @@
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 }));
module.exports = {
logError,
print
print,
};

View File

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

View File

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

View File

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