Як ми запустили онлайн-каву в Telegram-комʼюніті: досвід, бот і нетворкінг

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Привіт! Мене звати Сергій, я Java-розробник із 5-річним досвідом, у минулому працював як fullstack. За ці роки зрозумів: щоб підтримувати себе в професійній формі, варто постійно робити дві речі — працювати над pet-проєктами та ділитися досвідом з іншими. У цій статті я розповім, як виникла ідея Telegram-бота для регулярного нетворкінгу в нашій спільноті, з якими технічними викликами я зіткнувся та які результати ми отримали.

Як усе почалося

Разом із друзями ми створили спільноту 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 сесій).

Основна логіка

Формування пар відбувається в кілька етапів:

  1. Завантаження історичних пар з бази.
  2. Генерація пар з уникненням нещодавніх комбінацій.
  3. Якщо нічого не вдалось згенерувати за 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<>());
}

Мала група: повний перебір усіх можливих комбінацій

Для груп до 4-х людей я використовую рекурсивний бектрекінг, який шукає найкраще покриття без конфліктів.

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. Проєкт легкий, тому деплой реалізований просто:

  1. Збірка jar-файлу локально.
  2. Завантаження через SSH.
  3. Запуск через nohup.

База даних — RDS.

Я створив новий акаунт, тому в мене є free tier, тож витрати на інфраструктуру невеликі — до $5 на місяць.

Плани

У майбутньому планую:

  • Масштабуватись на більшу кількість груп. Технічно все для цього готово, шукаємо користувачів.
  • Додати фільтрацію пар за містом/інтересами. Це буде мати сенс, коли кількість одночасних учасників буде вище. Зараз маємо 20-25 учасників на одну каво-сесію, тому такий функціонал ще не додавав.
  • Додати аналітику активності спільноти. Це трохи виходить за рамки кавобота, але хочеться рости та розвиватись.

Підсумую: цей бот став для мене не просто pet-проєктом, а інструментом зміни атмосфери в спільноті. Він створює нові знайомства, підтримує живе спілкування і мотивує людей ділитися досвідом. За цей час я зустрів вже 20+ нових людей на каво-зустрічах і я цьому безмежно радий!

Якщо хочеш впровадити щось подібне у своїй спільноті — поділюсь досвідом.

Стартувало літнє зарплатне опитування DOU. Збираємо відповіді 15 тисяч ІТ-фахівців в анкеті. Долучайтеся!

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Похоже на thebreakfast.app который кстати тоже есть в Украине. Но я не против, конкуренция это хорошо.

Дякую за статтю! Дуже класна й цікава розробка! Прикольний спосіб зблизити ком’юніті, сподіваюся також якось доберуся до кави 😁

Дуже гарна ідея! Вдячний Сергію за створення цього бота та загалом за саму ідею. Завдяки цьому я вже встиг познайомитися з цікавими людьми і не шкодую, що вирішив доєднатися до цієї активності. Й надалі слідкуватиму за розвитком проєкту.

Сергію, ваш алгоритм унікального парування — це найцікавіша частина статті.
Видно, що ви думали над задачею, а не просто взяли Collections.shuffle(). Реалізація з історією, бектрекінгом для малих груп та обробкою непарної кількості учасників — це вже серйозний інженерний підхід, а не просто пет-проєкт «на колінці».
В результаті вийшов інструмент, який вирішує конкретну проблему спільноти. Дякую, що поділились і кодом, і тим, як саме ви думали над рішенням. Це дійсно мотивує робити свої проєкти так само вдумливо.

Підписатись на коментарі