Як ми запустили онлайн-каву в Telegram-комʼюніті: досвід, бот і нетворкінг
Привіт! Мене звати Сергій, я Java-розробник із
Як усе почалося
Разом із друзями ми створили спільноту Leetcoders — спочатку як вузький чат для спільного розв’язання алгоритмічних задач. З часом кожен почав запрошувати своїх знайомих — і менш ніж за два роки ми зросли з 4 людей до понад 700. Формат розширився: тепер ми проводимо офлайн зустрічі в Києві, алгоритмічні челенджі (наприклад, algorithm-challenge.com, про який я розповім в іншій раз), книжкові клуби, обговорення новин, щоденні квізи та багато іншого.
Звідки взялась ідея
Пів року тому я потрапив на вебінар з нетворкінгу, де спікер дав завдання: «Прямо зараз напишіть одній людині з вебінара в особисті та запросіть її на каву. Познайомтесь ближче, розширте коло знайомих». Геніальна ідея, подумав я, хочу мати теж саме і в нашій спільноті! Так народився концепт бота, що формує пари для неформального онлайн-спілкування (насправді бот може більше, але сконцентруємось на цій частині функціональності).
Архітектура та перші кроки
Задача: зібрати охочих до нетворкінгу, зберегти їхні імена та Telegram ID, сформувати пари й нагадати про зустріч. Подумати про розумний рандомайзер, щоб пари не співпадали декілька разів поспіль.
Базову функціональність реалізував за допомогою Java + Spring Boot. Для роботи з Telegram використав бібліотеку rubenlagus/TelegramBots.
Загалом отримувати інформацію від користувачів Telegram можна двома способами:
- Webhook — Telegram сам надсилає нам нову інформацію. Але він має знати, куди саме надіслати її, тому конкретну адресу треба зареєструвати вручну або при старті програми.
- Long Polling — це коли ти сам «ходиш» на сервер Telegram за інформацію раз за разом, перевіряючи, чи є щось новеньке.
Наш бот працює на Long Polling (для початку це найпростіший варіант), отримує повідомлення, реагує на inline-кнопки, зберігає активних учасників у базу даних.
Реєстрація бота вміщується в один клас:
@Slf4j @Component public class LeetcodersFriendBot implements SpringLongPollingBot, LongPollingSingleThreadUpdateConsumer { @Getter private final TelegramClient telegramClient; private final List<TelegramCommand> commands; private final BotProps botProps; public LeetcodersFriendBot(@Lazy List<TelegramCommand> commands, BotProps botProps) { this.commands = commands; this.botProps = botProps; telegramClient = new OkHttpTelegramClient(getBotToken()); } @Override public String getBotToken() { return botProps.token(); } @Override public LongPollingUpdateConsumer getUpdatesConsumer() { return this; } @Override public void consume(Update update) { try { for (TelegramCommand command : commands) { if (command.supports(update)) { command.execute(update, telegramClient); return; } } log.debug("No command found for update {}", update); } catch (Exception e) { log.error("Error processing update", e); } }
Цього вже достатньо для того, щоб отримувати оновлення від Telegram і їх опрацьовувати.
Запрошення на каву
Раз на тиждень у вівторок бот надсилає запрошення приєднатись до «онлайн-кави». Використав @Scheduled для автоматизації:
@Scheduled(cron = "0 0 5 * * TUE") public void startCoffeePoll() { @Scheduled(cron = "0 0 * * * *") public void startCoffeePoll() throws TelegramApiException { LocalDateTime now = LocalDateTime.now(); List<TelegramChatEntity> chatEntities = coffeeChatRepository.findByStartDateBetween(now.minusHours(1), now); log.info("Starting coffee poll {} for chats {}", now, chatEntities.size()); for (TelegramChatEntity chat : chatEntities) { log.info("Processing chat {}", chat); String participateCallback = ONLINE_COFFEE_PARTICIPATE_CALLBACK.formatted(chat.getChatId(), LocalDate.now()); InlineKeyboardButton participateButton = createInlineKeyboardButton(TO_PARTICIPATE_TEXT, participateCallback); InlineKeyboardButton rulesButton = createInlineKeyboardButton(RULES_BUTTON_TEXT, ONLINE_COFFEE_RULES_CALLBACK); SendMessage sendMessage = new SendMessage(chat.getChatId().toString(), ONLINE_COFFEE_TEXT); sendMessage.enableMarkdownV2(true); sendMessage.setReplyMarkup(InlineKeyboardMarkup.builder().keyboardRow(createInlineKeyboardRow(List.of(participateButton, rulesButton))).build()); OnlineCoffeeEntity entity = new OnlineCoffeeEntity(); entity.setDate(LocalDate.now()); entity.setIsActive(true); entity.setCoffeeName(participateCallback); entity.setChat(chat); Message sentMessage = leetcodersFriendBot.getTelegramClient().execute(sendMessage); entity.setMessageId(sentMessage.getMessageId()); onlineCoffeeRepository.save(entity); chat.setStartDate(chat.getStartDate().plusDays(7)); coffeeChatRepository.save(chat); leetcodersFriendBot.getTelegramClient().execute(PinChatMessage.builder() .messageId(sentMessage.getMessageId()) .chatId(sentMessage.getChatId()) .build()); } } }
Надсилається повідомлення і дві кнопки: взяти участь та почитати правила.
Коли користувачі натискають кнопку «П’ю каву з вами» — бот додає їх у список, повторне натискання прибирає зі списку. Ці події зберігаються в базу та додатково оновлюється повідомлення, в ньому видно всіх, хто бере участь.
Обробка реакцій
Кнопки обробляються через командний шаблон TelegramCommand. Для прикладу — вивід правил:
@Slf4j @Component public class OnlineCoffeeRulesCallback implements TelegramCommand { @Override public boolean supports(Update update) { return update.hasCallbackQuery() && update.getCallbackQuery().getData() != null && update.getCallbackQuery().getData().equals(ONLINE_COFFEE_RULES_CALLBACK); } @SneakyThrows @Override public void execute(Update update, TelegramClient client) { log.info("User {} requested rules", update.getCallbackQuery().getFrom().getId()); AnswerCallbackQuery answerCallbackQuery = new AnswerCallbackQuery(update.getCallbackQuery().getId()); answerCallbackQuery.setShowAlert(true); answerCallbackQuery.setText(RULES_ARE_SIMPLE); client.execute(answerCallbackQuery); } }
Коли користувач натискає кнопку «П’ю каву з вами», бот перевіряє по колбеку, чи ця кава-зустріч активна. Якщо так — додає або видаляє користувача зі списку й оновлює повідомлення зі списком учасників.
Для відображення учасників можна використовувати логін або, якщо його немає, First Name + Last Name.
Варто не забувати, що деякі символи не обробляються в Markdown, тому для коректного форматування відповідей використовуємо маленький утилітний метод:
public static String sanitizeField(String field) { String specialCharsRegex = "[\\\\_*\\[\\]()~`><+\\-=|{}.!]"; return StringUtils.defaultString(field).replaceAll(specialCharsRegex, "\\\\$0"); }
Ось так воно виглядає з заповненими даними людей:
Формування унікальних пар: як бот уникає повторень
Увечері запускається інший скедулер, що:
- зупиняє поточне опитування;
- формує випадкові пари з учасників.
Після запуску активності «онлайн-кава» стало зрозуміло, що простий Collections.shuffle() для формування пар швидко вичерпує себе. У спільноті почали повторюватись знайомства. Тому я реалізував алгоритм унікального парування з урахуванням історії: користувач не повинен потрапити в пару з кимось, із ким уже спілкувався раніше (принаймні протягом останніх 10 сесій).
Основна логіка
Формування пар відбувається в кілька етапів:
- Завантаження історичних пар з бази.
- Генерація пар з уникненням нещодавніх комбінацій.
- Якщо нічого не вдалось згенерувати за N спроб — fallback до більш простих варіантів.
public List<List<TelegramUserEntity>> generateUniquePairs(List<TelegramUserEntity> users, String sessionId) { Set<PairKey> pairingsToAvoid = loadRecentPairings(users); for (int attempt = 0; attempt < maxAttempts; attempt++) { List<List<TelegramUserEntity>> pairs = tryGeneratePairs(users, pairingsToAvoid); if (isValid(pairs, sessionPairings.get(sessionId))) { updateSessionPairings(pairs, sessionId); return pairs; } } // fallback return tryGeneratePairs(users, new HashSet<>()); }
Мала група: повний перебір усіх можливих комбінацій
Для груп до
private boolean tryPairingsRecursive(...) { if (available.isEmpty()) { bestPairs.addAll(currentPairs); return true; } // перебір усіх можливих унікальних пар }
Велика група: псевдорадомізований greedy
Для великих груп шальку терезів бере на себе стратегія «випадкової пари, яка не зустрічалась раніше». Якщо такої пари нема — повертаємось до першої з доступних.
Optional<TelegramUserEntity> bestPartner = availableUsers.stream() .filter(partner -> !pairingsToAvoid.contains(createPairKey(user1, partner))) .findFirst();
Робота з історією пар
Для відстеження попередніх зустрічей я використовую репозиторій pairing history. Він дозволяє швидко дізнатися, з ким людина вже була в парі, та виключити ці варіанти з наступної сесії.
Set<PairKey> loadRecentPairings(List<TelegramUserEntity> users) { return pairingHistoryRepository .findTop10ByUser1OrUser2OrderByMeetingDateDesc(user, user) .stream() .filter(pair -> isInCurrentList(pair, users)) .map(pair -> createPairKey(pair.getUser1(), pair.getUser2())) .collect(toSet()); }
Трійки — якщо учасників непарна кількість
Якщо кількість учасників непарна — останні троє об’єднуються в одну трійку. В історії зберігаються всі можливі комбінації в рамках цієї трійки.
if (group.size() == 3) { return Stream.of( new Pair(user1, user2), new Pair(user1, user3), new Pair(user2, user3) ); }
Підсумок
Ця логіка дозволяє уникати повторів та адаптувати поведінку під розмір спільноти.
Така побудова унікальних комбінацій з урахуванням історії — це вже не просто random, а розумне розбиття на пари, що покращує сприйняття людьми.
На скрині нижче вже можна побачити, як розбились люди на пари:
Масштабування
Коли один з адміністраторів іншої спільноти побачив функціональність — попросив встановити бота у своєму чаті. Щоб масштабуватись, довелось додати підтримку декількох Telegram-чатів, кастомізувати параметри запуску (день-час запуску/зупинки, окремий чат айді та топік).
В принципі вся конфігурація вмістилась в окрему табличку, куди я виніс потрібні на цей момент параметри. Тепер сервіс відпрацьовує щогодини та перевіряє, чи є якась робота в цей таймслот.
Хостинг і деплой
Бот працює на AWS EC2. Проєкт легкий, тому деплой реалізований просто:
- Збірка jar-файлу локально.
- Завантаження через SSH.
- Запуск через nohup.
База даних — RDS.
Я створив новий акаунт, тому в мене є free tier, тож витрати на інфраструктуру невеликі — до $5 на місяць.
Плани
У майбутньому планую:
- Масштабуватись на більшу кількість груп. Технічно все для цього готово, шукаємо користувачів.
- Додати фільтрацію пар за містом/інтересами. Це буде мати сенс, коли кількість одночасних учасників буде вище. Зараз маємо
20-25 учасників на одну каво-сесію, тому такий функціонал ще не додавав. - Додати аналітику активності спільноти. Це трохи виходить за рамки кавобота, але хочеться рости та розвиватись.
Підсумую: цей бот став для мене не просто pet-проєктом, а інструментом зміни атмосфери в спільноті. Він створює нові знайомства, підтримує живе спілкування і мотивує людей ділитися досвідом. За цей час я зустрів вже 20+ нових людей на каво-зустрічах і я цьому безмежно радий!
Якщо хочеш впровадити щось подібне у своїй спільноті — поділюсь досвідом.
Стартувало літнє зарплатне опитування DOU. Збираємо відповіді 15 тисяч ІТ-фахівців в анкеті. Долучайтеся!
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів