Skip to content

Commit 41c42b6

Browse files
committed
Implement the defense system against spammers in suggestions
Introduce a simple system, based on timeouts, to restrict spammers and flooders sending hundreds of messages within a short period of time.
1 parent 2a78bb7 commit 41c42b6

4 files changed

Lines changed: 149 additions & 0 deletions

File tree

app/bot.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import os
44
import asyncio
5+
import logging
56
from aiohttp import web
67
from aiotg import Bot, Chat, InlineQuery, CallbackQuery
78
from klocmod import LocalizationsContainer
9+
from typing import *
810

911
import msgdb
1012
import strconv
@@ -13,14 +15,21 @@
1315
from data.config import *
1416
from queryutil import *
1517
from userutil import *
18+
from timeoututil import *
1619

1720

1821
ISSUES_LINK = "https://{}/issues/".format(REPO_URL)
1922
DECRYPT_BUTTON_CACHE_TIME = 3600 # in seconds
23+
MAX_SUGGESTIONS = 5
24+
MAX_SUGGESTIONS_TIMEOUT = 720 # in minutes
2025

2126
bot = Bot(api_token=TOKEN, default_in_groups=True)
2227
localizations = LocalizationsContainer.from_file("app/localizations.ini")
2328
text_processors = TextProcessorsLoader(strconv)
29+
logger = logging.getLogger(__name__)
30+
31+
UserId = str
32+
suggestion_counters = {} # type: Dict[UserId, SuggestionsCounter]
2433

2534

2635
@bot.command("/start")
@@ -37,6 +46,20 @@ async def suggest(chat: Chat, match) -> None:
3746
username = escape_html(get_username_or_fullname(user))
3847
lang = localizations.get_lang(user['language_code'])
3948

49+
if user['id'] not in suggestion_counters:
50+
suggestion_counters[user['id']] = SuggestionsCounter()
51+
counter = suggestion_counters[user['id']]
52+
counter.increment()
53+
timeout_in_minutes = compute_timeout_in_minutes(counter, MAX_SUGGESTIONS, MAX_SUGGESTIONS_TIMEOUT)
54+
logger.debug(counter)
55+
logger.debug("User ID: %d, username: %s, timeout: %d minutes", user['id'], username, timeout_in_minutes)
56+
if counter.minutes_elapsed >= timeout_in_minutes:
57+
counter.reset()
58+
counter.update_timestamp()
59+
if counter.value > MAX_SUGGESTIONS:
60+
chat.send_text(lang['suggestion_timeout'].format(timeout_in_minutes))
61+
return
62+
4063
first_line = match.group(1)
4164
rest_lines = chat.message['text'].split('\n')[1:]
4265
suggestion = escape_html(first_line + '\n' + '\n'.join(rest_lines))

app/localizations.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ suggestion_report_template = User {} <b>suggests</b>:
1010
1111
{}
1212
suggestion_sent = You suggestion was sent to the developer!
13+
suggestion_timeout = Oops! The defense system against spammers and flooders has been activated! You will be able to send a new suggestion in {:d} minutes.
1314
suggest_create_issue = To keep an eye on your suggestion, you can additionally [create an issue on GitHub]({}).
1415
decrypt = Decrypt
1516
missing_original_text = Oops! I lost the original text :(
@@ -35,6 +36,7 @@ suggestion_report_template = Пользователь {} <b>предлагает
3536
3637
{}
3738
suggestion_sent = Предложение отправлено разработчику!
39+
suggestion_timeout = Упс! Сработала защита от спамеров и флудеров! Новое предложение можно будет отправить через {:d} минут.
3840
suggest_create_issue = Чтобы иметь возможность следить за судьбой предложения, можно дополнительно [создать тикет на GitHub]({}).
3941
decrypt = Расшифровать
4042
missing_original_text = Расшифровка потерялась :(

app/timeoututil.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Utility functions and classes for implementations of different timeouts."""
2+
3+
import math
4+
import time
5+
6+
7+
__all__ = ['SuggestionsCounter', 'compute_timeout_in_minutes']
8+
9+
10+
class SuggestionsCounter:
11+
"""A simple counter that remembers the last time it was adjusted."""
12+
13+
_value = 0 # type: int
14+
_last_change_timestamp = 0.0 # type: float
15+
16+
def __init__(self):
17+
self.update_timestamp()
18+
19+
def increment(self) -> int:
20+
self._value += 1
21+
return self._value
22+
23+
def reset(self) -> None:
24+
self._value = 0
25+
26+
@property
27+
def value(self) -> int:
28+
return self._value
29+
30+
@property
31+
def last_change_timestamp(self) -> float:
32+
return self._last_change_timestamp
33+
34+
@property
35+
def seconds_elapsed(self) -> int:
36+
""":return: the number of seconds elapsed since the last change."""
37+
return math.floor(time.time() - self._last_change_timestamp)
38+
39+
@property
40+
def minutes_elapsed(self) -> int:
41+
""":return: the number of minutes elapsed since the last change."""
42+
return math.floor(self.seconds_elapsed / 60)
43+
44+
def update_timestamp(self) -> None:
45+
self._last_change_timestamp = time.time()
46+
47+
def __str__(self) -> str:
48+
return "{class_name}(value={value}, last_change_timestamp={last_change_timestamp}, " \
49+
"seconds_elapsed={seconds_elapsed}, minutes_elapsed={minutes_elapsed})".format(
50+
class_name=self.__class__.__name__,
51+
value=self.value,
52+
last_change_timestamp=self.last_change_timestamp,
53+
seconds_elapsed=self.seconds_elapsed,
54+
minutes_elapsed=self.minutes_elapsed
55+
)
56+
57+
58+
def compute_timeout_in_minutes(counter: SuggestionsCounter, max_suggestions: int, max_suggestions_timeout: int) -> int:
59+
"""
60+
Compute timeout applying a multiplier taking into account the value of the counter.
61+
62+
Example of calculations:
63+
64+
MAX_SUGGESTIONS = 3
65+
MAX_SUGGESTIONS_TIMEOUT = 720
66+
67+
counter multiplier timeout_in_minutes
68+
-------------------------------------------
69+
1 1 720
70+
2 1 720
71+
3 1 720
72+
4 1 720
73+
5 2 1440
74+
6 3 2160
75+
7 4 2880
76+
.. .. ..
77+
"""
78+
multiplier = counter.value - max_suggestions if counter.value > max_suggestions else 1
79+
return max_suggestions_timeout * multiplier

tests/test_timeoututil.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pytest
2+
from unittest import mock
3+
from timeoututil import SuggestionsCounter, compute_timeout_in_minutes
4+
5+
6+
@pytest.fixture
7+
def counter() -> SuggestionsCounter:
8+
return SuggestionsCounter()
9+
10+
11+
class TestSuggestionsCounter:
12+
@staticmethod
13+
def test_increment(counter):
14+
assert counter.value == 0
15+
for i in range(1, 10):
16+
assert counter.increment() == counter.value == i
17+
counter.reset()
18+
assert counter.value == 0
19+
20+
@staticmethod
21+
def test_elapsed_time_properties():
22+
seconds = [15, 55, 60, 72, 125, 250]
23+
minutes = [0, 0, 1, 1, 2, 4]
24+
25+
def seconds_generator():
26+
yield 0
27+
for n in seconds:
28+
yield n
29+
yield n
30+
31+
with mock.patch("time.time", side_effect=seconds_generator()):
32+
counter = SuggestionsCounter()
33+
for expected_seconds, expected_minutes in zip(seconds, minutes):
34+
assert counter.seconds_elapsed == expected_seconds
35+
assert counter.minutes_elapsed == expected_minutes
36+
37+
38+
def test_compute_timeout_in_minutes(counter):
39+
max_suggestions = 3
40+
max_suggestions_timeout = 720
41+
42+
results = [720, 720, 720, 720, 1440, 2160, 2880]
43+
for i, expected in zip(range(1, 10), results):
44+
assert i == counter.increment()
45+
assert compute_timeout_in_minutes(counter, max_suggestions, max_suggestions_timeout) == expected

0 commit comments

Comments
 (0)