Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add captcha feature #27

Merged
merged 5 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v3

- name: Use Node 19
- name: Use Node 20
uses: actions/setup-node@v1
with:
node-version: 19
node-version: 20

- name: Install dependencies
run: yarn
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v2

- name: Use Node 19
- name: Use Node 20
uses: actions/setup-node@v1
with:
node-version: 19
node-version: 20

- name: Install dependencies
run: yarn
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
FROM node:19-alpine AS builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn
COPY . .
RUN yarn build

FROM node:19-alpine
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ The bot replies in English by default. You can change the language using the `/s
- [x] Set Group Language:
- [x] `/set_language en`
- [x] `/set_language ptbr`
- [x] Captcha:
- New members will face a simple captcha on groups with this enabled.
- All CAPTCHA messages are self-destructable, except the rejection ones.
- [x] `/enable_captcha`: Will enable the captcha in the group. Can only be executed by admins.
- [x] `/disable_captcha`: Will disable the captcha in the group. Can only be executed by admins.
- [x] Self preservation habilities
- [x] Handle deleted registered users and deregister those when identified
- [x] Auto bans accounts with Cyrillic Characters _(huge sorry to all the eastern europeans for this, russians made me do it)_
Expand Down
1 change: 1 addition & 0 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ app = "gringobot"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
primary_region = "ams"

[env]
NODE_ENV="production"
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,31 @@
"license": "MIT",
"type": "module",
"engines": {
"node": ">=19"
"node": ">=20"
},
"dependencies": {
"async-retry": "^1.3.1",
"await-to-js": "^3.0.0",
"country-code-emoji": "^2.3.0",
"dotenv": "^8.2.0",
"dotenv": "^16.4.5",
"got": "^12.5.3",
"i18n-iso-countries": "^6.5.0",
"js-yaml": "^4.1.0",
"lowdb": "^5.1.0",
"lowdb": "^7.0.0",
"mkdirp": "^1.0.4",
"mustache": "^4.2.0",
"pino": "^8.10.0",
"pino-pretty": "^9.3.0",
"sharp": "^0.33.2",
"svg-captcha": "^1.4.0",
"telegraf": "^4.11.2",
"telegram-format": "^2.1.0",
"tslib": "^2.1.0",
"yup": "^0.32.9"
},
"devDependencies": {
"@types/async-retry": "^1.4.2",
"@types/dotenv": "^8.2.0",
"@types/js-yaml": "^4.0.5",
"@types/mkdirp": "^1.0.1",
"@types/mustache": "^4.2.2",
Expand Down
6 changes: 1 addition & 5 deletions src/autoDeleteMessages.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { to } from 'await-to-js';
import retry, { RetryFunction } from 'async-retry';
import { BotContext } from './context.js';

const expired = (createdAt: number, timeout: number) => {
const minute = 60000;
return Math.floor((Date.now() - createdAt) / minute) >= timeout;
};
import { expired } from './utils/expired.js';

export const runMessageRecycling = async (
ctx: BotContext
Expand Down
104 changes: 104 additions & 0 deletions src/autoKickCaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { to } from 'await-to-js';
import retry, { RetryFunction } from 'async-retry';
import { BotContext } from './context.js';
import { expired } from './utils/expired.js';
import { createMemberMention } from './member.js';

// TODO: make timeout configurable
const messageTimeout = 2;

export const runCaptchaRecycling = async (
ctx: BotContext
): Promise<void> => {
const database = await ctx.loadDatabase();

if (!database.isCaptchaEnabled) {
return;
}

const captcha = await database.getCaptcha();
const waitingUsers = captcha?.waitingUsers ?? {};

const promises = Object.entries(waitingUsers).map(
async ([userId, entry]) => {
const hasExpired = expired(entry.timestamp, messageTimeout);

ctx.logger.info(
`Captcha issued at ${entry.timestamp}: expiration status: '${
hasExpired ? 'expired' : 'not expired'
}'`
);

if (!hasExpired) {
/** the captcha still hasn't expired. */
return;
}

const id = Number(userId);
const chatId = ctx.chat?.id;

ctx.logger.info(
`Trying to kick user with id "${id}" from the chat "${chatId}", since captcha expired..`
);

const kickUser: RetryFunction<void> = async (bail, attempt) => {
const [kickUserError, kicked] = await to(
ctx.banChatMember(id, undefined, {
revoke_messages: true,
})
);

if (kicked) {
ctx.logger.info(
`Kicked user with id "${id}" from the chat "${chatId}"`
);
return;
}

if (kickUserError) {
ctx.logger.error(
`[Attempt No ${attempt}]: Failed to kick user with id "${id}" from the chat "${chatId}"`
);

const errorMessage =
// @ts-ignore
kickUserError.response.description;

ctx.logger.error(errorMessage);

throw kickUserError;
}
};

const [kickUserError] = await to(
retry(kickUser, {
retries: 3,
})
);

if (kickUserError) {
ctx.logger.error(
`Failed to kick user with id "${id}" from the chat:`
);
ctx.logger.error(kickUserError);
}

if (!kickUserError) {
ctx.logger.info(
`Kicked user with id "${id}" from the chat "${chatId}". Adding user to chat kicklist.`
);

const { t } = ctx.i18n;
await ctx.database.removeUserFromCaptchaWaitlist(entry.user);
await ctx.database.addUserToCaptchaKicklist(entry.user);
await ctx.replyWithMarkdown(
t('kick', 'userGotKickedCaptcha', {
kickedUser: createMemberMention(entry.user),
})
);
}
}
);

await Promise.all(promises);
};
26 changes: 26 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export enum Command {
FindRemoteMemberFrom = 'find_remote_member_from',
Kick = 'kick',
SetLanguage = 'set_language',
EnableCaptcha = 'enable_captcha',
DisableCaptcha = 'disable_captcha',
}

enum PortugueseCommandAlias {
Expand All @@ -36,6 +38,8 @@ enum PortugueseCommandAlias {
RegisterRemoteMember = 'estou_remoto',
FindRemoteMemberTo = 'quem_remoto_para',
FindRemoteMemberFrom = 'quem_remoto_do',
EnableCaptcha = 'habilitar_captcha',
DisableCaptcha = 'desabilitar_captcha',
}

enum EnglishCommandAlias {
Expand All @@ -49,6 +53,8 @@ enum EnglishCommandAlias {
DeregisterMemberFrom = 'leave',
FindMember = 'whereami',
SetLanguage = 'set_lang',
EnableCaptcha = 'captcha_on',
DisableCaptcha = 'captcha_off',
}

const DefaultCommandDescriptions: Record<Command, string> = {
Expand Down Expand Up @@ -79,6 +85,10 @@ const DefaultCommandDescriptions: Record<Command, string> = {
[Command.Kick]: 'Ban mentioned user (Admin only)',
[Command.SetLanguage]:
'Sets the language that GringoBot should use in this (Admin only)',
[Command.EnableCaptcha]:
'Enable captcha for new members (Admin only)',
[Command.DisableCaptcha]:
'Disable captcha for new members (Admin only)',
};

const CommandDescriptionMap: Record<string, string> = {
Expand All @@ -101,6 +111,10 @@ const CommandDescriptionMap: Record<string, string> = {
DefaultCommandDescriptions[Command.PingMembersAt],
[EnglishCommandAlias.FindMember]:
DefaultCommandDescriptions[Command.FindMember],
[EnglishCommandAlias.EnableCaptcha]:
DefaultCommandDescriptions[Command.EnableCaptcha],
[EnglishCommandAlias.DisableCaptcha]:
DefaultCommandDescriptions[Command.DisableCaptcha],
[PortugueseCommandAlias.PingAdmins]: 'Notifica admins.',
[PortugueseCommandAlias.RegisterMemberAt]:
'Registra você em uma localização específica.',
Expand Down Expand Up @@ -128,6 +142,10 @@ const CommandDescriptionMap: Record<string, string> = {
'Encontra pessoas que estão trabalhando remoto para um país',
[PortugueseCommandAlias.RegisterRemoteMember]:
'Se registra como trabalhando remoto',
[PortugueseCommandAlias.EnableCaptcha]:
'Habilita CAPTCHA para novos membros do grupo',
[PortugueseCommandAlias.DisableCaptcha]:
'Desabilita CAPTCHA para novos membros do grupo',
};

export const CommandDescriptions: BotCommand[] = Object.keys(
Expand Down Expand Up @@ -209,4 +227,12 @@ export const CommandAliases: Record<Command, string[]> = {
Command.SetLanguage,
EnglishCommandAlias.SetLanguage,
],
[Command.EnableCaptcha]: [
Command.EnableCaptcha,
PortugueseCommandAlias.EnableCaptcha,
],
[Command.DisableCaptcha]: [
Command.DisableCaptcha,
PortugueseCommandAlias.DisableCaptcha,
],
};
6 changes: 3 additions & 3 deletions src/commands/deregisterMemberFrom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
getCountryNameForCountryCode,
} from '../countries.js';

export const cmdDeregisterMemberFrom: MiddlewareFn<BotContext> = async (
ctx
) => {
export const cmdDeregisterMemberFrom: MiddlewareFn<
BotContext
> = async (ctx) => {
const i18n = ctx.i18n;
const unsafeCountryName = markdown
.escape(ctx.command.args ?? '')
Expand Down
6 changes: 3 additions & 3 deletions src/commands/deregisterRemoteMember.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { MiddlewareFn } from 'telegraf';
import { BotContext } from '../context.js';

export const cmdDeregisterRemoteMember: MiddlewareFn<BotContext> = async (
ctx
) => {
export const cmdDeregisterRemoteMember: MiddlewareFn<
BotContext
> = async (ctx) => {
const i18n = ctx.i18n;

await ctx.database.removeRemoteMember(ctx.safeUser.id);
Expand Down
35 changes: 35 additions & 0 deletions src/commands/disableCaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Middleware } from 'telegraf';
import { BotContext } from '../context.js';
import { to } from 'await-to-js';

export const cmdDisableCaptcha: Middleware<BotContext> = async (
ctx
) => {
const { t } = ctx.i18n;
const hasAdminAccess = await ctx.checkAdminAccess();

if (!hasAdminAccess) {
return ctx.replyWithAutoDestructiveMessage(
t('errors', 'mustBeAdminToUseCommand', {
mention: ctx.safeUser.mention,
})
);
}

const [err] = await to(ctx.database.disableCaptcha());

if (err) {
ctx.logger.error(`Failed to disable captcha.`, err);
return ctx.replyWithAutoDestructiveMessage(
t('errors', 'failedToDisableCaptcha', {
mention: ctx.safeUser.mention,
})
);
}

return ctx.replyWithAutoDestructiveMessage(
t('captcha', 'disabled', {
mention: ctx.safeUser.mention,
})
);
};
35 changes: 35 additions & 0 deletions src/commands/enableCaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Middleware } from 'telegraf';
import { BotContext } from '../context.js';

export const cmdEnableCaptcha: Middleware<BotContext> = async (
ctx
) => {
const { t } = ctx.i18n;
const hasAdminAccess = await ctx.checkAdminAccess();

if (!hasAdminAccess) {
return ctx.replyWithAutoDestructiveMessage(
t('errors', 'mustBeAdminToUseCommand', {
mention: ctx.safeUser.mention,
})
);
}

try {
await ctx.database.enableCaptcha();

return ctx.replyWithAutoDestructiveMessage(
t('captcha', 'enabled', {
mention: ctx.safeUser.mention,
})
);
} catch (err) {
ctx.logger.error(`Failed to enable captcha.`);
ctx.logger.error(err);
return ctx.replyWithAutoDestructiveMessage(
t('errors', 'failedToEnableCaptcha', {
mention: ctx.safeUser.mention,
})
);
}
};
6 changes: 3 additions & 3 deletions src/commands/listCountryMemberCount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { countryCodeEmoji } from 'country-code-emoji';
import { BotContext } from '../context.js';
import { getCountryNameForCountryCode } from '../countries.js';

export const cmdListCountryMemberCount: MiddlewareFn<BotContext> = async (
ctx
) => {
export const cmdListCountryMemberCount: MiddlewareFn<
BotContext
> = async (ctx) => {
const i18n = ctx.i18n;

const locationIndex = ctx.database.getLocationIndex();
Expand Down
Loading
Loading