Skip to content

Commit

Permalink
Refactor funds, add sponsors to api, more efficient sponsors refresh,…
Browse files Browse the repository at this point in the history
… impersonation
  • Loading branch information
tectonick committed Dec 22, 2024
1 parent 9d051d3 commit aa6fedb
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 83 deletions.
16 changes: 15 additions & 1 deletion api/bot/routers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import FundsRepository from "@repositories/funds";
import StatusRepository from "@repositories/status";
import UsersRepository from "@repositories/users";

import { getDonationsSummary } from "@services/export";
import { getDonationsSummary, SponsorshipLevel, SponsorshipLevelToName } from "@services/export";
import logger from "@services/logger";
import {
filterAllPeopleInside,
Expand Down Expand Up @@ -186,6 +186,20 @@ router.get("/donations", async (req, res) => {
return res.json(await getDonationsSummary(fund, limit));
});

router.get("/sponsors", hassTokenOptional, (req, res) => {
const sponsors = UsersRepository.getSponsors();
res.json(
sponsors.map(s => {
return {
userid: req.authenticated ? s.userid : undefined,
username: s.username,
first_name: s.first_name,
sponsorship: SponsorshipLevelToName.get(s.sponsorship as SponsorshipLevel),
};
})
);
});

router.get("/wiki/tree", async (_, res, next) => {
try {
const list = await wiki.listPagesAsTree();
Expand Down
15 changes: 13 additions & 2 deletions api/bot/routers/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const ApiTextCommandsList = [
description: "Наши открытые сборы",
regex: "^funds$",
},
{
command: "sponsors",
description: "Наши почетные спонсоры",
regex: "^sponsors$",
},
{
command: "events",
description: "Мероприятия у нас",
Expand Down Expand Up @@ -108,15 +113,21 @@ router.get("/donate", (_, res) => {
res.send(message);
});

router.get("/sponsors", (_, res) => {
const sponsors = UsersRepository.getSponsors();
const message = TextGenerators.getSponsorsList(sponsors, true);
res.send(message);
});

router.get("/status", async (_, res) => {
const state = StatusRepository.getSpaceLastState();
let content = `🔐 Статус спейса неопределен`;

const allUserStates = UserStateService.getRecentUserStates();
const inside = allUserStates.filter(filterPeopleInside);
const going = allUserStates.filter(filterPeopleGoing);
const climateResponse = await requestToEmbassy(`/climate`);
const climateInfo = (await climateResponse.json()) as SpaceClimate;
const climateResponse = await requestToEmbassy(`/climate`).catch(() => null);
const climateInfo = climateResponse?.ok ? ((await climateResponse.json()) as SpaceClimate) : null;
const todayEvents = apiConfig.features.calendar ? await getTodayEvents() : null;

content = TextGenerators.getStatusMessage(state, inside, going, todayEvents, climateInfo, {
Expand Down
20 changes: 20 additions & 0 deletions api/bot/swagger-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@
}
}
},
"/text/sponsors": {
"get": {
"description": "",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/text/status": {
"get": {
"description": "",
Expand Down Expand Up @@ -257,6 +267,16 @@
}
}
},
"/api/sponsors": {
"get": {
"description": "",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/wiki/tree": {
"get": {
"description": "",
Expand Down
16 changes: 15 additions & 1 deletion bot/core/HackerEmbassyBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
DEFAULT_TEMPORARY_MESSAGE_TIMEOUT,
FULL_PERMISSIONS,
IGNORE_UPDATE_TIMEOUT,
IMPERSONATION_MARKER,
MAX_MESSAGE_LENGTH,
RESTRICTED_PERMISSIONS,
} from "./constants";
Expand Down Expand Up @@ -470,6 +471,11 @@ export default class HackerEmbassyBot extends TelegramBot {
return !text.startsWith("/") || forAnotherBot;
}

private extractImpersonatedUser(text: string): string | number {
const identifier = text.split(IMPERSONATION_MARKER)[1];
return identifier.startsWith("@") ? identifier.slice(1) : Number.parseInt(identifier);
}

async routeMessage(message: TelegramBot.Message) {
try {
// Skip old updates
Expand Down Expand Up @@ -498,7 +504,13 @@ export default class HackerEmbassyBot extends TelegramBot {
if (!route) return;

// Prepare context
const user = UserService.prepareUser(message.from as TelegramBot.User);
const actualUser = UserService.prepareUser(message.from as TelegramBot.User);
const impersonatedUser =
hasRole(actualUser, "admin") && text.includes(IMPERSONATION_MARKER)
? UserService.getUser(this.extractImpersonatedUser(text))
: null;
const user = impersonatedUser ?? actualUser;

const messageContext = this.startContext(message, user, command);
messageContext.language = isSupportedLanguage(user.language) ? user.language : DEFAULT_LANGUAGE;
messageContext.messageThreadId = message.is_topic_message ? message.message_thread_id : undefined;
Expand All @@ -511,6 +523,8 @@ export default class HackerEmbassyBot extends TelegramBot {
// Parse global modifiers and set them to the context
let textToMatch = text.replace(commandWithCase, command);

if (impersonatedUser) textToMatch = textToMatch.slice(0, textToMatch.indexOf(IMPERSONATION_MARKER));

for (const key of Object.keys(messageContext.mode)) {
if (textToMatch.includes(`-${key}`)) messageContext.mode[key as keyof BotMessageContextMode] = true;
textToMatch = textToMatch.replace(` -${key}`, "");
Expand Down
1 change: 1 addition & 0 deletions bot/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const IGNORE_UPDATE_TIMEOUT = 8; // Seconds from bot api
export const DEFAULT_TEMPORARY_MESSAGE_TIMEOUT = 8000; // Milliseconds
export const DEFAULT_CLEAR_QUEUE_TIMEOUT = 5000;
export const DEFAULT_CLEAR_QUEUE_LENGTH = 10;
export const IMPERSONATION_MARKER = "~~";

export const RESTRICTED_PERMISSIONS = {
can_send_messages: false,
Expand Down
106 changes: 55 additions & 51 deletions bot/handlers/funds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import config from "config";

import { BotConfig } from "@config";
import UsersRepository from "@repositories/users";
import { User } from "@data/models";
import FundsRepository, { COSTS_PREFIX } from "@repositories/funds";
import UserService from "@services/user";
import {
convertCurrency,
DefaultCurrency,
Expand All @@ -18,7 +20,6 @@ import * as ExportHelper from "@services/export";
import logger from "@services/logger";
import { getToday } from "@utils/date";
import { getImageFromPath } from "@utils/filesystem";
import { UserStateService } from "@services/status";
import { replaceUnsafeSymbolsForAscii } from "@utils/text";

import HackerEmbassyBot from "../core/HackerEmbassyBot";
Expand Down Expand Up @@ -242,15 +243,16 @@ export default class FundsHandlers implements BotHandlers {

static async refreshSponsorshipsHandler(bot: HackerEmbassyBot, msg?: Message) {
try {
logger.info("Recalculating sponsorships");

for (const user of UsersRepository.getUsers()) {
const sponsorship = await ExportHelper.getSponsorshipLevel(user);
const updatedUser = { ...user, sponsorship };

// TODO - move both to service
UsersRepository.updateUser(user.userid, updatedUser);
UserStateService.refreshCachedUser(updatedUser);
const donations = FundsRepository.getAllDonations(false, true, ExportHelper.getSponsorshipStartPeriodDate());
const sponsorDataMap = ExportHelper.getUserDonationMap(donations);

for (const { user, donations } of sponsorDataMap) {
const oldSponsorship = user.sponsorship;
user.sponsorship = await ExportHelper.getSponsorshipLevel(donations);
if (oldSponsorship !== user.sponsorship) {
UserService.saveUser(user);
logger.info(`Updated sponsorship for ${user.username} from ${oldSponsorship} to ${user.sponsorship}`);
}
}

if (msg) bot.sendMessageExt(msg.chat.id, t("funds.refreshsponsorships.success"), msg);
Expand All @@ -261,6 +263,21 @@ export default class FundsHandlers implements BotHandlers {
}
}

static async getAnimeImageForDonation(value: number, currency: string, user: User) {
const valueInDefaultCurrency = await convertCurrency(value, currency, DefaultCurrency);

if (!valueInDefaultCurrency) throw new Error("Failed to convert currency");

if (value === 42069 || value === 69420 || value === 69 || value === 420) {
return getImageFromPath(`./resources/images/memes/comedy.jpg`);
} else if (user.username && botConfig.funds.alternativeUsernames.includes(user.username)) {
return getImageFromPath(`./resources/images/anime/guy.jpg`);
} else {
const happinessLevel = value < 10000 ? 1 : value < 20000 ? 2 : value < 40000 ? 3 : value < 80000 ? 4 : 5; // lol
return getImageFromPath(`./resources/images/anime/${happinessLevel}.jpg`);
}
}

static async addDonationHandler(
bot: HackerEmbassyBot,
msg: Message,
Expand All @@ -270,26 +287,27 @@ export default class FundsHandlers implements BotHandlers {
fundName: string
) {
try {
// Prepare and validate input
const value = parseMoneyValue(valueString);
const preparedCurrency = await prepareCurrency(currency);
if (isNaN(value) || !preparedCurrency) throw new Error("Invalid value or currency");

const user =
UsersRepository.getUserByName(sponsorName.replace("@", "")) ??
UsersRepository.getUserByUserId(helpers.getMentions(msg)[0]?.id);
const accountant = bot.context(msg).user;

if (!user) return bot.sendMessageExt(msg.chat.id, t("general.nouser"), msg);

const fund = FundsRepository.getFundByName(fundName);

if (!fund) return bot.sendMessageExt(msg.chat.id, t("funds.adddonation.nofund"), msg);

// Check if user has already donated to this fund
const existingUserDonations = FundsRepository.getDonationsForName(fundName).filter(
donation => donation.user_id === user.userid
);
const hasAlreadyDonated = existingUserDonations.length > 0;

if (isNaN(value) || !preparedCurrency) throw new Error("Invalid value or currency");

// Add donation to the fund
const lastInsertRowid = FundsRepository.addDonationTo(
fund.id,
user.userid,
Expand All @@ -298,55 +316,41 @@ export default class FundsHandlers implements BotHandlers {
preparedCurrency
);

const valueInDefaultCurrency = await convertCurrency(value, preparedCurrency, DefaultCurrency);

if (!valueInDefaultCurrency) throw new Error("Failed to convert currency");

let animeImage: Nullable<Buffer> = null;

if (value === 42069 || value === 69420 || value === 69 || value === 420) {
animeImage = await getImageFromPath(`./resources/images/memes/comedy.jpg`);
} else if (user.username && botConfig.funds.alternativeUsernames.includes(user.username)) {
animeImage = await getImageFromPath(`./resources/images/anime/guy.jpg`);
} else {
const happinessLevel =
valueInDefaultCurrency < 10000
? 1
: valueInDefaultCurrency < 20000
? 2
: valueInDefaultCurrency < 40000
? 3
: valueInDefaultCurrency < 80000
? 4
: 5; // lol
animeImage = await getImageFromPath(`./resources/images/anime/${happinessLevel}.jpg`);
}

if (!animeImage) throw new Error("Failed to get image");

const sponsorship = await ExportHelper.getSponsorshipLevel(user);
// Update user sponsorship level
const userDonations = FundsRepository.getDonationsOf(
user.userid,
false,
false,
ExportHelper.getSponsorshipStartPeriodDate()
);
const sponsorship = await ExportHelper.getSponsorshipLevel(userDonations);
const hasUpdatedSponsorship = user.sponsorship !== sponsorship;

// TODO - move both to service
UsersRepository.updateUser(user.userid, { sponsorship });
UserStateService.refreshCachedUser({ ...user, sponsorship });
if (hasUpdatedSponsorship) {
user.sponsorship = sponsorship;
UserService.saveUser(user);
}

const caption = TextGenerators.getNewDonationText(
// Send message to the chat
const newDonationText = TextGenerators.getNewDonationText(
user,
value,
preparedCurrency,
lastInsertRowid,
fundName,
sponsorship,
hasAlreadyDonated
);
const sponsorshipText = hasUpdatedSponsorship ? "\n" + TextGenerators.getNewSponsorshipText(user, sponsorship) : "";
const caption = `${newDonationText}${sponsorshipText}`;
const animeImage = await FundsHandlers.getAnimeImageForDonation(value, preparedCurrency, user);

await bot.sendPhotoExt(msg.chat.id, animeImage, msg, {
caption,
});
animeImage
? await bot.sendPhotoExt(msg.chat.id, animeImage, msg, { caption })
: await bot.sendMessageExt(msg.chat.id, caption, msg);

// Send notification to Space
bot.context(msg).mode.silent = true;

const textInSpace = replaceUnsafeSymbolsForAscii(caption);
const textInSpace = replaceUnsafeSymbolsForAscii(newDonationText);

return Promise.allSettled([
EmbassyHandlers.textinspaceHandler(bot, msg, textInSpace),
Expand Down
37 changes: 15 additions & 22 deletions bot/textGenerators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export function getStatusMessage(
stateText += "\n";

// Misc
stateText += climateInfo ? getClimateMessage(climateInfo, options) : REPLACE_MARKER;
stateText += climateInfo ? getClimateMessage(climateInfo, options) : options.isApi ? "" : REPLACE_MARKER;
stateText += !options.isApi
? t("status.status.updated", {
updatedDate: new Date().toLocaleString("RU-ru").replace(",", "").substring(0, 21),
Expand Down Expand Up @@ -521,42 +521,35 @@ export function getNewDonationText(
currency: string,
lastInsertRowid: number | bigint,
fundName: string,
sponsorship: SponsorshipLevel,
hasAlreadyDonated: boolean
) {
const sponsorshipEmoji = SponsorshipLevelToEmoji.get(sponsorship);
const tiername = SponsorshipLevelToName.get(sponsorship);
const oldSponsorship = user.sponsorship as SponsorshipLevel;
const username = formatUsername(user.username);

const donationtext = t(hasAlreadyDonated ? "funds.adddonation.increased" : "funds.adddonation.success", {
username,
return t(hasAlreadyDonated ? "funds.adddonation.increased" : "funds.adddonation.success", {
username: formatUsername(user.username),
value: toBasicMoneyString(value),
currency,
donationId: lastInsertRowid,
fundName,
});
}

const sponsorshipText =
sponsorship && oldSponsorship !== sponsorship
? "\n" +
t("funds.adddonation.sponsorship", {
username,
emoji: sponsorshipEmoji,
tiername,
})
: "";

return `${donationtext}${sponsorshipText}`;
export function getNewSponsorshipText(user: User, sponsorship: SponsorshipLevel) {
return t("funds.adddonation.sponsorship", {
username: formatUsername(user.username),
emoji: SponsorshipLevelToEmoji.get(sponsorship),
tiername: SponsorshipLevelToName.get(sponsorship),
});
}

export function getSponsorsList(sponsors: User[]): string {
export function getSponsorsList(sponsors: User[], isApi = false): string {
return sponsors
.sort((a, b) =>
b.sponsorship === a.sponsorship
? (a.username ?? a.first_name ?? "").localeCompare(b.username ?? b.first_name ?? "")
: (b.sponsorship ?? 0) - (a.sponsorship ?? 0)
)
.map(s => `${SponsorshipLevelToEmoji.get(s.sponsorship ?? SponsorshipLevel.None) ?? ""} ${userLink(s)}`)
.map(
s =>
`${SponsorshipLevelToEmoji.get(s.sponsorship ?? SponsorshipLevel.None) ?? ""} ${isApi ? formatUsername(s.username, false, true) : userLink(s)}`
)
.join("\n");
}
Loading

0 comments on commit aa6fedb

Please sign in to comment.