Skip to content

Instantly share code, notes, and snippets.

@AMD-NICK
Last active December 29, 2023 12:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save AMD-NICK/4fc1afa498f698f40b5f33e40ef81916 to your computer and use it in GitHub Desktop.
Save AMD-NICK/4fc1afa498f698f40b5f33e40ef81916 to your computer and use it in GitHub Desktop.
Monobank Alerts Telegram Bot
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)
-- 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)
@Sheveg27
Copy link

Sheveg27 commented May 8, 2021

Можно добавить функцию выбора карт, по которой будут приходить уведомления / по которой формируется выписка, в случае если карт на аккаунте несколько?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment