Last active
December 29, 2023 12:52
-
-
Save AMD-NICK/4fc1afa498f698f40b5f33e40ef81916 to your computer and use it in GitHub Desktop.
Monobank Alerts Telegram Bot
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
local APIURL = "https://api.monobank.ua" | |
/* | |
Ошибки: | |
400 = Period must be no more than 31 days | |
400 = Missing required field 'from' | |
403 = Unknown 'X-Token' (левый токен) | |
429 = Too many requests | |
*/ | |
local MONO_MT = {} | |
MONO_MT.__index = MONO_MT | |
local function logError(token, err) | |
local line = DateTime() .. ": " .. token .. " > " .. err | |
MsgN(line) | |
file.Append("mono_errors.txt", line .. "\n") | |
end | |
-- В cb 1 - okdata, 2 - errdescr, 3 - errcode (mb nil) | |
function MONO_MT:_Get(cb, method) | |
local REQ = http.request(APIURL .. method, "GET") | |
REQ:SetHeaders({["X-Token"] = self.token}) | |
REQ:Exec(function(code, sDat) | |
local dat = assert(util.JSONToTable(sDat),"MONO No Json: " .. tostring(sDat)) | |
if code ~= 200 then | |
local err = dat.errorDescription | |
logError(self.token, err) | |
cb(nil, err, code) | |
return | |
end | |
cb(dat) | |
end,function(reason) | |
logError(self.token, "REQUEST ERROR: " .. reason) | |
cb(nil, reason, nil) | |
end) | |
end | |
-- 1 раз в 5 мин | |
-- https://api.monobank.ua/docs/#operation--bank-currency-get | |
function MONO_MT:GetCurrencyRates(cb) | |
self:_Get(cb, "/bank/currency") | |
end | |
-- 1 раз в 60 сек | |
-- https://api.monobank.ua/docs/#operation--personal-client-info-get | |
function MONO_MT:GetPersonalInfo(cb) | |
self:_Get(function(dat, err, code) | |
if dat then | |
self.cached = os.time() | |
self.name = dat.name | |
self.accs = dat.accounts -- balance, id, currencyCode, creditLimit, cashbackType | |
end | |
cb(dat, err, code) | |
end, "/personal/client-info") | |
end | |
-- 1 раз в 60 сек | |
-- Максимальний час за який можливо отримувати виписку 31 доба (2678400 секунд) | |
-- https://api.monobank.ua/docs/#operation--personal-statement--account---from---to--get | |
function MONO_MT:GetStatement(cb, iFrom, iTo_, sAccount_) | |
self:_Get(cb, "/personal/statement/" .. (sAccount_ or "0") .. "/" .. iFrom .. "/" .. (iTo_ or "")) | |
end | |
function Monobank(token) | |
return setmetatable({ | |
token = token | |
},MONO_MT) | |
end | |
-- local MONO = Monobank("") | |
-- MONO:GetCurrencyRates(PRINT) | |
-- MONO:GetPersonalInfo(PRINT) | |
-- MONO:GetStatement(PRINT, os.time() - DAYS(7), nil, 0) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- deps: | |
-- fl, fn, PRINT, json, pollgram, mysql | |
-- table.RemoveByValue, table.HasValue | |
-- escapeMarkdown, tlgMention | |
-- string.Split, utf8sub | |
-- if not TLG_MONO then return end -- для хуков | |
assert(require("env").polling_uid_services, "env.polling_uid_services not set") | |
require("utf8") -- utf8? here? | |
TLG_MONO = TLG_MONO or pollgram( require("env").tg_monobot ) | |
local bot = TLG_MONO | |
-- bot:StartPolling() | |
function bot.require(what) | |
return require("bots.monoalertsbot." .. what) | |
end | |
local api = bot.require("mono_api") | |
bot.require("cmd_airalerts") | |
bot.CATEGORIES = bot.require("mcc") | |
local ex = require("tlib.includes.expire") | |
-- ISO 4217 | |
bot.CURRENCIES = setmetatable({ | |
[840] = "$", -- USD | |
[980] = "₴", -- UAH | |
[643] = "₽", -- RUB | |
[978] = "€", -- EUR | |
[348] = "Ft", -- HUF -- Венгерский форинт | |
[949] = "₺", -- TRY -- Турецкая лира | |
[203] = "Kč", -- CZK | |
[191] = "Kn", -- HRK | |
[826] = "£", -- GBP -- Фунт | |
[756] = "Fr", -- CHF -- Швейцарский франк | |
[933] = "Br", -- BYN -- Беларусс рубль | |
[124] = "C$", -- CAD -- Канадский доллар | |
[208] = "kr", -- DKK -- Данская крона | |
[985] = "zł", -- PLN -- Польский злотый | |
},{ | |
__index = function(_, k) | |
return "currency #" .. tostring(k) | |
end | |
}) | |
local function fetchRates() -- limit вроде 2 в мин | |
return deferred.new(function(d) | |
local cached_rates = ex.get("mono_rates") | |
if not cached_rates then | |
return api.get(nil, "/bank/currency"):next(function(rates) | |
ex.setex("mono_rates", rates, 60) | |
d:resolve(rates) | |
end) | |
end | |
d:resolve( cached_rates ) | |
end) | |
end | |
fetchRates() | |
local function updateUsdUahRate() | |
fetchRates():next(function(rates) | |
for _,rate in ipairs(rates) do | |
if rate.currencyCodeB == 980 and rate.currencyCodeA == 840 then | |
bot.usd_uah_rate = rate.rateBuy or rate.rateCross | |
print("MONO: USD/UAH rate:", bot.usd_uah_rate) | |
break | |
end | |
end | |
end) | |
end | |
-- updateUsdUahRate() | |
local QSd = require("qsd").deferred_query | |
local function parseRow(row) | |
local user = {} | |
user.token = row.token | |
user.name = row.name | |
user.accounts = util.JSONToTable(row.accounts_json) | |
user.chats = util.JSONToTable(row.chats_json) | |
return user | |
end | |
-- response https://i.imgur.com/oppgMWJ.png | |
local function api_client_info(token) | |
return deferred.new(function(d) | |
ex.remember("mono_client_info:" .. token, 60, function(push_data) | |
api.get(token, "/personal/client-info"):next(push_data, fp{d.reject, d}) | |
end, fp{d.resolve, d}) | |
end):next(nil, function(err) | |
bot.reply(TLG_AMD).text( | |
err.code == 403 and ("Некорректный токен (" .. token .. "). Возможно, он уже устарел") | |
or err.description) | |
error(err) | |
end) | |
end | |
local function getUserByToken(token) | |
return QSd("SELECT * FROM bot_monoalerts WHERE token = ?", token):next(function(info) | |
return info[1] and parseRow(info[1]) | |
end) | |
end | |
local function getUserByAccID(acc_id) | |
return QSd("SELECT * FROM bot_monoalerts WHERE accounts_json LIKE '%" .. acc_id .. "%'"):next(function(info) | |
return info[1] and parseRow(info[1]) | |
end) | |
end | |
local deleteTokenFromChat = function(token, chatid) | |
return getUserByToken(token):next(function(user) | |
PRINT({deleteToken = user}) | |
-- table.RemoveByValue | |
for i,chat_id in ipairs(user and user.chats or {}) do | |
if chatid == chat_id then | |
table.remove(user.chats, i) | |
break | |
end | |
end | |
if user.chats[1] then -- остались еще записи | |
print("МОНО. Остались еще чаты. Запись с БД не уносим") | |
return QSd("UPDATE bot_monoalerts SET chats_json = ? WHERE token = ?", util.TableToJSON(user.chats), token, true):next(function(res) | |
return res.affected_rows > 0 | |
end) | |
else | |
print("МОНО. Удаляем всю запись целиком") | |
QSd("DELETE FROM bot_monoalerts WHERE token = ?", token) | |
return true | |
end | |
end) | |
end | |
local function txToText(tx, tx_acc) | |
local currencyCode = tx_acc.currencyCode or 980 | |
local creditLimit = tx_acc.creditLimit or 0 | |
local icon = tx.amount < 0 and "🔴" or "💚" | |
local emoji = bot.CATEGORIES:getByMcc(tx.mcc) or tostring(tx.mcc) -- "❔" | |
local decimal = tx.amount / 100 | |
local acc_currency = bot.CURRENCIES[currencyCode] | |
local secs_ago = os.time() - tx.time | |
local nice_ago = secs_ago > 60 and (timeToStr(secs_ago) .. " назад") or "" | |
local usd_am = bot.usd_uah_rate and math.abs(decimal / bot.usd_uah_rate) | |
local usd_ams = usd_am and string.format("(`%.2f`$)", usd_am) or "" | |
local txt = "" | |
txt = txt .. string.format("%s %g%s %s %s\n", icon, decimal, acc_currency, usd_ams, nice_ago) | |
if tx.currencyCode ~= currencyCode then -- не в валюте счета | |
local am = tx.operationAmount / 100 | |
local tx_currency = bot.CURRENCIES[tx.currencyCode] | |
txt = txt .. string.format("В валюте: %g%s\n", am, tx_currency) | |
txt = txt .. string.format("Курс: %g%s\n", math.Round(decimal / am, 2), tx_currency) | |
end | |
if tx.commissionRate ~= 0 then | |
txt = txt .. string.format("Комиссия: %g%s\n", tx.commissionRate / 100, acc_currency) | |
end | |
if tx.cashbackAmount ~= 0 then | |
local percent = tx.cashbackAmount / tx.amount * 100 * -1 -- меняем знак, потому что amount минусовое число | |
txt = txt .. string.format("Кешбэк: %g%s (%i%%)\n", tx.cashbackAmount / 100, acc_currency, percent) | |
end | |
txt = txt .. string.format("Остаток: %g%s\n", (tx.balance - creditLimit) / 100, acc_currency) | |
txt = txt .. string.format("\n```\n%s %s\n```", emoji, tx.comment or tx.description) | |
-- PRINT(tx, txt) | |
return txt | |
end | |
--[[ | |
local text = txToText({ | |
id = 'MOtIHIr70iSbhyXK', | |
mcc = 4829, | |
currencyCode = 980, | |
receiptId = '02E9-A9X4-193H-MHT4', | |
originalMcc = 4829, | |
amount = -1234567, | |
balance = 30750, | |
hold = true, | |
operationAmount = -1234567, | |
cashbackAmount = 0, | |
time = 1660321825, | |
commissionRate = 0, | |
description = 'TEST TEST TEST', | |
}, { | |
currencyCode = 980, | |
creditLimit = 0, | |
}) | |
TLG_MONO.reply(TLG_AMD).markdown(text) | |
]] | |
local function getBalanceEmoji(bal_uah) | |
if bal_uah <= 0 then return "💩" end | |
if bal_uah <= 300 then return "🥲" end | |
if bal_uah < 1000 then return "🥺" end | |
if bal_uah < 5000 then return "😮" end | |
if bal_uah < 9000 then return "🤑" end | |
return "💎" | |
end | |
-- for _,sum in ipairs({-100, 0, 100, 500, 1000, 5000, 10000, 20000}) do | |
-- print(string.format("Sum: %s, Emoji: %s", sum, getBalanceEmoji(sum))) | |
-- end | |
local send_tx = function(tx, acc_id, user) | |
api_client_info(user.token):next(function(fresh_user) | |
-- PRINT({fresh_user = fresh_user, user = user, acc_id = acc_id}) -- https://i.imgur.com/oppgMWJ.png | |
local tx_acc | |
for _,acc in ipairs(fresh_user.accounts) do | |
if acc.id == acc_id then | |
tx_acc = acc | |
break | |
end | |
end | |
assert(tx_acc, "Свежий аккаунт для " .. acc_id .. " не найден") | |
local bal_uah = tx.currencyCode == 980 and tx.balance / 100 | |
local emoji = bal_uah and getBalanceEmoji(bal_uah) or "😎" | |
local name_psc = user.name and user.name:Split(" ") or {"Name", "No"} | |
local name = string.format("_%s %s._", name_psc[2], name_psc[1]:utf8sub(1,1)) -- _Владислав Ф._ | |
if name_psc[2]:sub(1,1) == "@" then | |
name = escapeMarkdown(name_psc[2]) -- @amd\\_nick | |
end | |
local msg = emoji .. " " .. name .. "\n\n" .. txToText(tx, tx_acc) | |
for _,chat_id in ipairs(user.chats) do | |
bot.reply(chat_id).markdown(msg):next(nil, function(err) | |
PRINT({sendTxError = err}) | |
if err.error_code == 400 and (err.description:find("chat not found") or err.description:find("bot was blocked by the user")) then | |
-- QSd("DELETE FROM bot_monoalerts WHERE chats_json LIKE '%" .. chat_id .. "%'") -- не протестил, стремно | |
bot.reply(TLG_AMD).markdown( | |
"ТРУБУЕТСЯ РУЧНОЕ ДЕЙСТВИЕ. Error 400 in " .. tlgMention("This chat", chat_id) .. ". " .. | |
"Owner: " .. user.name .. ". ") | |
return | |
elseif err.error_code == 403 and err.description:find("bot was kicked from the group chat") then | |
bot.reply(TLG_AMD).text("Удали вручную chat_id=" .. chat_id .. " из базы. Бота там нет. Лень было автоматиизировать") | |
return | |
end | |
bot.reply(TLG_AMD).text("Необработанная ошибка при отправке транзакции:\n\n" .. util.TableToJSON({err = err, msg = msg, user = user}, true)) | |
end) | |
end | |
end, function(err) | |
PRINT({send_tx_err = err}) | |
bot.reply(TLG_AMD).text("Не могу выполнить api_client_info, ошибка: " .. err.description .. "\n\n" .. util.TableToJSON(tx) .. "\n\n" .. util.TableToJSON(user)) | |
end) | |
end | |
function bot:SendTx(tx, acc_id) | |
if #acc_id == 31 then return end -- банка | |
getUserByAccID(acc_id):next(function(user) | |
-- PRINT({SendTx = {tx = tx, acc_id = acc_id, user = user}}) | |
if not user then | |
self.reply(TLG_AMD).markdown(escapeMarkdown(acc_id) .. " не кеширован. Сделать /personal/client-info на все текущие акки и определить чей это ид") | |
PRINT({uncached = {tx = tx, acc_id = acc_id}}) | |
return | |
end | |
send_tx(tx, acc_id, user) | |
end) | |
end | |
bot.command("start", function(msg, reply) | |
local txt = | |
"Я присылаю оповещения, когда по карте проходит новая транзакция и рассчитан на использование семьей.\n\n" .. | |
"Создайте семейный чат, или добавьте меня в существующий, затем попросите каждого ввести /addaccount.\n\n" .. | |
"Конечно, вам никто не запрещает делать все это в личном чате" | |
reply.text(txt) | |
end) | |
local add_account = function(ctx, token, user) | |
if table.HasValue(user and user.chats or {}, ctx.chat.id) then | |
ctx.reply.text("Аккаунт уже добавлен в этот чат") | |
return | |
end | |
api_client_info(token):next(function(dat) | |
if user then table.insert(user.chats, ctx.chat.id) end | |
local accs_list = fl.map(dat.accounts, fl.property("id")) | |
QSd("REPLACE INTO `bot_monoalerts` (`token`,`name`,`accounts_json`,`chats_json`) VALUES (?,?,?,?);", | |
token, dat.name, util.TableToJSON(accs_list), util.TableToJSON(user and user.chats or {ctx.chat.id})) | |
ctx.reply.markdown( | |
"Вы привязали аккаунт `" .. dat.name .. "` 🎉\n" .. | |
"Введите /accounts для детальной информации" | |
) | |
local login = ctx.from.username and ("@" .. ctx.from.username) | |
local fn,ln = ctx.from.first_name, ctx.from.last_name or "" | |
local fullname = fn .. " " .. ln | |
local chat_name = ctx.chat.title or login or fullname | |
bot.reply(TLG_AMD).markdown(tlgMention(dat.name, ctx.from.id) .. " добавил аккаунт в " .. tlgMention(chat_name, ctx.chat.id)) | |
api.post(token, "/personal/webhook", { | |
webHookUrl = "https://poll.gmod.app/" .. require("env").polling_uid_services .. "?appname=MONO" -- #TODO constant (bottom of the file too) | |
}) | |
end, function(err) | |
ctx.reply.text( | |
err.code == 403 and ("Некорректный токен (" .. token .. "). Возможно, он уже устарел") | |
or err.description) | |
end) | |
end | |
bot.command("addaccount", function(ctx) | |
local token = ctx.args()[1] | |
if not token or #token ~= 44 then | |
local caption = "Для работы нужен API токен\n\n" .. | |
"Перейдите по адресу https://api.monobank.ua, получите токен и повторите ввод команды, указав его после нее\n\n" .. | |
"Должно получиться как на скриншоте" | |
ctx.reply.photo("AgADBAAD3KkxGxm1nVBCuITEXA6MfrT_sBoABNuKBB9IV-K-wZ0CAAEC", caption) | |
return | |
end | |
getUserByToken(token):next(function(user) | |
return add_account(ctx, token, user) | |
end):next(fp{PRINT, "/addaccount OK"}, fp{PRINT, "/addaccount ERR"}) | |
end) | |
local send_user = function(ctx, user) | |
local lname,fname = unpack(user.name:Split(" ")) | |
local name = fname .. " " .. string.utf8sub(lname, 1,1) .. "." | |
local txt = "<pre>" .. name .. "</pre>\n\n" | |
txt = txt .. "ID счетов:\n- " .. table.concat(user.accounts, "\n- ") | |
ctx.reply.inlineKeyboard({{ | |
ggramDataButton("❌ Удалить", {delete = user.token}) | |
}}).html(txt) | |
end | |
local send_users = function(ctx, users) | |
for _,row in ipairs(users) do | |
getUserByToken(row.token):next(function(user) | |
send_user(ctx, user, row.token) | |
end) | |
end | |
end | |
bot.command("accounts", function(ctx) | |
-- #todo возможны коллизии. При chat_id 1 и 123 найдет. Как костыль ниже добавить фильтр | |
QSd("SELECT * FROM bot_monoalerts WHERE chats_json LIKE '%" .. ctx.chat.id .. "%'"):next(function(users) | |
if not users[1] then | |
ctx.reply.text("У вас нет аккаунтов, но вы можете добавить их через /addaccount") | |
return | |
end | |
send_users(ctx, users) | |
end) | |
end) | |
bot.command("rates", function(ctx) | |
fetchRates():next(function(rates) | |
local txt = "` BUY SELL`\n" | |
local add_info = function(v) | |
local base = rawget(bot.CURRENCIES, v.currencyCodeA) | |
if not base then return end | |
local buy, sell = v.rateBuy or v.rateCross, v.rateSell or v.rateCross | |
txt = txt .. string.format("`% 8.4f % 8.4f` - %s", buy, sell, base) .. "\n" | |
end | |
for _,v in ipairs(rates) do | |
if v.currencyCodeB == 980 then -- только UAH пары | |
add_info(v) | |
end | |
end | |
ctx.reply.markdown(txt) | |
end, function(err) | |
PRINT({cant_rates = err}) | |
ctx.reply.text("Не удалось получить курсы валют") | |
end) | |
end) | |
bot.callback(function(ctx) | |
local query = ctx.callback_query | |
local msg = query.message | |
local chat_id = msg.chat.id | |
local data = ctx.json() | |
if not data then return end -- str | |
if data.delete then -- token | |
deleteTokenFromChat(data.delete, chat_id):next(function(is_deleted) | |
bot.reply(chat_id).editMarkdown(msg, is_deleted and "Аккаунт больше не отслеживается" or "Не удалось удалить. Уже удален?") | |
end, function(err) PRINT({mono_delete_error = err}) bot.reply(TLG_AMD).text("Ошибка удаления аккаунта. Подробнее в консоли") end) | |
end | |
end, "monoalerts") | |
local function migrate(old_chat_id, new_chat_id) | |
QSd("SELECT * FROM bot_monoalerts WHERE chats_json LIKE '%" .. old_chat_id .. "%'"):next(function(res) | |
for _,row in ipairs(res) do | |
local dat = parseRow(row) | |
local chats = dat.chats | |
for i,chat_id in ipairs(chats) do | |
if chat_id == old_chat_id then | |
chats[i] = new_chat_id | |
end | |
end | |
QSd("UPDATE bot_monoalerts SET chats_json = ? WHERE token = ?", util.TableToJSON(chats), row.token) | |
:next(fp{PRINT, "bla4 OK"}, fp{PRINT, "bla4 ERR"}) | |
end | |
end) | |
end | |
bot.update(function(ctx) | |
if ctx.message and ctx.message.migrate_to_chat_id then | |
local old_chat_id = ctx.message.chat.id | |
local new_chat_id = ctx.message.migrate_to_chat_id | |
migrate(old_chat_id, new_chat_id) | |
bot.reply(new_chat_id).text("Чат мигрировал на новый ид. " .. string.format("Старый: %s, новый: %s", old_chat_id, new_chat_id)) | |
end | |
end, "migrate_chat_id") | |
local function processPush(res) | |
PRINT("mono webhook", res) | |
-- res = https://img.qweqwe.ovh/1564062883780.png | |
if res.type == "StatementItem" then | |
local acc_id = res.data.account | |
local tx = res.data.statementItem | |
bot:SendTx(tx, acc_id) | |
-- if tx.time % 2 == 0 then -- просто так | |
updateUsdUahRate() | |
-- end | |
end | |
end | |
require("tlib.includes.polls").poller:handle("MONO", processPush) | |
-- polls.handle("MONO", processPush) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Можно добавить функцию выбора карт, по которой будут приходить уведомления / по которой формируется выписка, в случае если карт на аккаунте несколько?