From 30f7203c5fe69e875dbd29a4da9fe0c5ab1ddc9b Mon Sep 17 00:00:00 2001 From: Er2 Date: Mon, 16 Aug 2021 11:47:49 +0300 Subject: [PATCH] move from bot --- .gitignore | 4 ++ LICENSE | 19 ++++++ api.lua | 137 +++++++++++++++++++++++++++++++++++++++++ core.lua | 133 ++++++++++++++++++++++++++++++++++++++++ init.lua | 1 + inline.lua | 156 ++++++++++++++++++++++++++++++++++++++++++++++ multipart.lua | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++ readme.org | 29 +++++++++ tools.lua | 103 +++++++++++++++++++++++++++++++ 9 files changed, 749 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 api.lua create mode 100644 core.lua create mode 100644 init.lua create mode 100644 inline.lua create mode 100644 multipart.lua create mode 100644 readme.org create mode 100644 tools.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c51bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Emacs +*~ +\#*# + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e241737 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Zlib License + +Copyright (c) 2021 Er2 + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgement in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/api.lua b/api.lua new file mode 100644 index 0000000..56ed3dc --- /dev/null +++ b/api.lua @@ -0,0 +1,137 @@ +--[[ API Library + -- (c) Er2 2021 + -- Zlib License +--]] + +local tools = require 'api.tools' +local json = require 'json' +local events = require 'events' +local api = { + request = function(s, ...) return tools.request(s.token, ...) end +} +api.__index = api -- Make class +events(api) -- inheritance + +-- parse arguments +local function argp(cid, rmp, pmod, dwp) + return + type(cid) == 'table' and cid.chat.id or cid, + type(rmp) == 'table' and json.encode(rmp) or rmp, + (type(pmod) == 'boolean' and pmod == true) and 'MarkdownV2' or pmod, + dwp == nil and true or dwp +end + +-- Getters without params + +function api:getMe() return self:request 'getMe' end +function api:getMyCommands() return self:request 'getMyCommands' end + +-- Getters with params + +function api:getChat(cid) return self:request('getChat', {chat_id = cid}) end + +-- Setters + +function api:send(msg, txt, pmod, dwp, dnot, rtmid, rmp) + msg, rmp, pmod, dwp = argp(msg, rmp, pmod, dwp) + + if txt and #txt >= 4096 then + txt = txt:sub(0, 4092) .. '...' + end + + return self:request('sendMessage', { + chat_id = msg, + text = txt, + parse_mode = pmod, + disable_web_page_preview = dwp, + disable_notification = dnot, + reply_to_message_id = rtmid, + reply_markup = rmp, + }) +end + +function api:reply(msg, txt, pmod, dwp, rmp, dnot) + _, rmp, pmod, dwp = argp(msg, rmp, pmod, dwp) + return self:request('sendMessage', { + chat_id = msg.chat.id, + text = txt, + parse_mode = pmod, + disable_web_page_preview = dwp, + disable_notification = dnot, + reply_to_message_id = msg.message_id, + reply_markup = rmp, + }) +end + +function api:forward(cid, frcid, mid, dnot) + return self:request('forwardMessage', { + chat_id = cid, + from_chat_id = frcid, + disable_notification = dnot, + message_id = mid, + }) +end + +function api:sendPhoto(cid, f, cap, pmod, dnot, rtmid, rmp) + cid, rmp, pmod = argp(cid, rmp, pmod) + return self:request('sendPhoto', { + chat_id = cid, + caption = cap, + parse_mode = pmod, + disable_notification = dnot, + reply_to_message_id = rtmid, + reply_markup = rmp, + }, { photo = f }) +end + +function api:sendDocument(cid, f, cap, pmod, dnot, rtmid, rmp) + cid, rmp, pmod = argp(cid, rmp, pmod) + return self:request('sendDocument', { + chat_id = cid, + caption = cap, + parse_mode = pmod, + disable_notification = dnot, + reply_to_message_id = rtmid, + reply_markup = rmp, + }, { document = f }) +end + +function api:sendPoll(cid, q, opt, anon, ptype, mansw, coptid, expl, pmode, oper, cdate, closed, dnot, rtmid, rmp) + cid, rmp, pmode = argp(cid, rmp, pmode) + opt = type(opt) == 'string' and opt or json.encode(opt) + anon = type(anon) == 'boolean' and anon or false + mansw = type(mansw) == 'boolean' and mansw or false + return self:request('sendPoll', { + chat_id = cid, + question = q, + options = opt, + is_anonymous = anon, + type = ptype, + allows_multiple_answers = mansw, + correct_option_id = coptid, + explanation = expl, + explanation_parse_mode = pmode, + open_period = oper, + close_date = cdate, + is_closed = closed, + disable_notification = dnot, + reply_to_message_id = rtmid, + reply_markup = rmp, + }) +end + +function api:answerCallback(id, txt, alrt, url, ctime) + return self:request('answerCallbackQuery', { + callback_query_id = id, + text = txt, + show_alert = alrt, + url = url, + cache_time = ctime, + }) +end + +function api:setMyCommands(cmds) + return self:request('setMyCommands', { commands = json.encode(cmds) }) +end + +return api diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..04baaf5 --- /dev/null +++ b/core.lua @@ -0,0 +1,133 @@ +--[[ Core file + -- (c) Er2 2021 + -- Zlib License +--]] + +local tools = require 'api.tools' +local api = require 'api.api' +api.__index = api + +function api:_ev(t, i, name, ...) + local v = t[i] + if v.name == name then + v.fn(self, ...) + if v.type == 'once' then table.remove(t, i) end + end +end + +-- UPDATES -- +function api:getUpdates(lim, offs, tout, allowed) + allowed = type(allowed) == 'table' and tools.json.encode(allowed) or allowed + return self:request('getUpdates', { + timeout = tout, + offset = offs, + limit = lim, + allowed_updates = allowed + }) +end + +local function receiveUpdate(self, update) + if update.message then + local msg = update.message + local cmd, to = tools.fetchCmd(msg.text or '') + if cmd and (not to or to == self.info.username) then + -- need /cmd@bot in groups + if (msg.chat.type == 'group' or msg.chat.type == 'supergroup') + and not to then return end + + local args = {} + msg.text = msg.text:sub(#cmd + #(to or '') + 3) + for s in msg.text:gmatch '%S+' do table.insert(args, s) end + + msg.cmd = cmd + msg.args = args + + return self:emit('command', msg) + elseif cmd then return end + + self:emit('message', msg) + + elseif update.edited_message then + self:emit('messageEdit', update.edited_message) + + elseif update.channel_post then self:emit('channelPost', update.channel_post) + elseif update.edited_channel_post then self:emit('channelPostEdit', update.edited_channel_post) + + elseif update.poll then self:emit('poll', update.poll) + elseif update.poll_answer then self:emit('pollAnswer', update.poll_answer) + + elseif update.callback_query then self:emit('callbackQuery', update.callback_query) + elseif update.inline_query then self:emit('inlineQuery', update.inline_query) + elseif update.shipping_query then self:emit('shippingQuery', update.shipping_query) + elseif update.pre_checkout_query then self:emit('preCheckoutQuery', update.pre_checkout_query) + + elseif update.chosen_inline_result then self:emit('inlineResult', update.chosen_inline_result) + end +end + +function api:_getUpd(lim, offs, ...) + local u, ok = self:getUpdates(lim, offs, ...) + if not ok or not u or (u and type(u) ~= 'table') or not u.result then return end + for _, v in pairs(u.result) do + offs = v.update_id + 1 + if type(v) == 'table' then + self:emit('update', v) + receiveUpdate(self, v) + end + end + return offs +end + +function api:_loop(lim, offs, ...) + while api.runs do + local o = self:_getUpd(lim, offs, ...) + offs = o and o or offs + end + self:getUpdates(lim, offs, ...) +end + +-- RUN -- +function api:run(lim, offs, tout, al) + lim = tonumber(lim) or 1 + offs = tonumber(offs) or 0 + tout = tonumber(tout) or 0 + + self.runs = true + self:emit 'ready' + + self.co = coroutine.create(api._loop) + coroutine.resume(self.co, self, lim, tout, offs, al) +end + +function api:destroy() self.runs = false end + +function api:login(token, thn) + self.token = assert(token or self.token, 'Provide token!') + + repeat + local r, o = self:getMe() + if o and r then self.info = r end + until (self.info or {}).result + + self.info = self.info.result + self.info.name = self.info.first_name + + if type(thn) == 'function' then thn(self) end + + if not self.nr then self:run() end +end + +return function(opts) + if not token or type(token) ~= 'string' then token = nil end + + local self = setmetatable({}, api) + if type(opts) == 'table' then + if opts.token then self.token = opts.token end + if opts.norun then self.nr = true end + if not opts.noinl then + self.inline = require('etc.api.inline')(self) + end + end + + return self +end diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..065a80f --- /dev/null +++ b/init.lua @@ -0,0 +1 @@ +return require 'api.core' diff --git a/inline.lua b/inline.lua new file mode 100644 index 0000000..9c3d11f --- /dev/null +++ b/inline.lua @@ -0,0 +1,156 @@ +--[[ Inline query library + -- (c) Er2 2021 + -- Zlib License +--]] + +local inline = {} +inline.__index = inline -- Make class + +function inline.query(id, from, q, off, ct, loc) + return { + id = tostring(id), + from = from, + query = q, + offset = off, + chat_type = ct, + location = loc, + } +end + +function inline.result(type, id, ...) + type = tostring(type) + local t = setmetatable({ + type = type, + id = tostring(tonumber(id) or 1), + }, inline) + local a = {...} + if t.type == 'article' then t.title, t.url, t.hide_url, t.description = table.unpack(a) + + elseif t.type == 'photo' then + t.photo_url, t.photo_width, t.photo_height, t.title, t.description, + t.caption, t.parse_mode, t.caption_entities + = table.unpack(a) + + elseif t.type == 'gif' or t.type == 'mpeg4_gif' then + local url, width, height, duration + url, width, height, duration, t.title, t.caption, t.parse_mode, t.caption_entities + = table.unpack(a) + + if t.type == 'gif' then + t.gif_url, t.gif_width, t.gif_height, t.gif_duration + = url, width, height, duration + else + t.mpeg4_url, t.mpeg4_width, t.mpeg4_height, t.mpeg4_duration + = url, width, height, duration + end + + elseif t.type == 'video' then + t.video_url, t.mime_type, t.title, t.caption, t.parse_mode, + t.caption_entities, t.video_width, t.video_height, t.video_duration, t.description + = table.unpack(a) + + elseif t.type == 'audio' or t.type == 'voice' then + t.title, t.caption, t.parse_mode, t.caption_entities = table.unpack(a, 2) + + if t.type == 'audio' then + t.audio_url, t.performer, t.audio_duration = a[1], a[6], a[7] + else + t.voice_url, t.voice_duration = a[1], a[6] + end + + elseif t.type == 'document' then + t.title, t.caption, t.parse_mode, t.caption_entities, t.document_url, + t.mime_type, t.description = table.unpack(a) + + elseif t.type == 'location' or t.type == 'venue' then + t.latitude, t.longitude, t.title = table.unpack(a, 1, 3) + + if t.type ~= 'venue' then + t.horizontal_accurancy, t.live_period, t.heading, t.proximity_alert_radius + = table.unpack(a, 4, 7) + else + t.address, t.foursquare_id, t.foursquare_type, t.google_place_id, t.google_place_type + = table.unpack(a, 4, 8) + end + + elseif t.type == 'contact' then + t.phone_number, t.first_name, t.last_name, t.vcard, + t.reply_markup, t.input_message_content + = table.unpack(a) + + elseif t.type == 'game' then t.game_short_name = a[1] + end + + return t +end + +function inline:thumb(url, width, height, mime) + if self.type == 'audio' + or self.type == 'voice' + or self.type == 'game' + then return self end + + self.thumb_url = tostring(url) + + if width and height and ( + self.type == 'article' + or self.type == 'document' + or self.type == 'contact' + or self.type == 'location' + or self.type == 'venue' + ) then + self.thumb_width = tonumber(width) + self.thumb_height = tonumber(height) + end + + if mime and ( + self.type == 'gif' + or self.type == 'mpeg4_gif' + ) then self.thumb_mime_type = mime end + + return self +end + +function inline:keyboard(...) + if not self.type then return self end + local k = {} + + for _, v in pairs {...} do + if type(v) == 'table' then + table.insert(k, v) + end + end + self.reply_markup = k + + return self +end + +-- Author itself not understands why this funciton needed +-- so not recommends to use it +function inline:messCont(a) + if self.type == 'game' or self.type == 'article' then + self.input_message_content = a + end + return self +end + +function inline:answer(id, res, ctime, per, noff, pmt, pmp) + print(dump(res)) + if res.id then res = {res} end + return self:request('answerInlineQuery', { + inline_query_id = id, + results = res, + cache_time = ctime, + is_personal = per, + next_offset = noff, + switch_pm_text = pmt, + switch_pm_parameter = pmp, + }) +end + +return function(api) + local self = setmetatable({ + request = function(_, ...) api:request(...) end + }, inline) + return self +end diff --git a/multipart.lua b/multipart.lua new file mode 100644 index 0000000..361470b --- /dev/null +++ b/multipart.lua @@ -0,0 +1,167 @@ +-- based on https://github.com/catwell/lua-multipart-post +-- MIT License + +local ltn12 = require 'ltn12' +local mp = { + CHARSET = 'UTF-8', + LANGUAGE = '' +} + +-- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99 +local function urlencode(url) + if url == nil then return end + url = url:gsub("\n", "\r\n") + url = url:gsub("([^%w_%- . ~])", function(c) return string.format("%%%02X", string.byte(c)) end) + url = url:gsub(" ", "+") + return url +end +mp.urlencode = urlencode + +local function fmt(p, ...) + if select('#', ...) == 0 then + return p + end + return string.format(p, ...) +end + +local function tprintf(t, p, ...) + t[#t+1] = fmt(p, ...) +end + +local function section_header(r, k, extra) + tprintf(r, 'content-disposition: form-data; name="%s"', k) + if extra.filename then + tprintf(r, '; filename="%s"', extra.filename) + tprintf( + r, "; filename*=%s'%s'%s", + mp.CHARSET, mp.LANGUAGE, urlencode(extra.filename) + ) + end + if extra.content_type then + tprintf(r, '\r\ncontent-type: %s', extra.content_type) + end + if extra.content_transfer_encoding then + tprintf( + r, '\r\ncontent-transfer-encoding: %s', + extra.content_transfer_encoding + ) + end + tprintf(r, '\r\n\r\n') +end + +function mp.boundary() + local t = {"BOUNDARY-"} + for i=2,17 do t[i] = string.char(math.random(65, 90)) end + t[18] = "-BOUNDARY" + return table.concat(t) +end + +local function encode_header_to_table(r, k, v, boundary) + local _t = type(v) + + tprintf(r, "--%s\r\n", boundary) + if _t == "string" then + section_header(r, k, {}) + elseif _t == "table" then + assert(v.data, "invalid input") + local extra = { + filename = v.filename or v.name, + content_type = v.content_type or v.mimetype + or "application/octet-stream", + content_transfer_encoding = v.content_transfer_encoding + or "binary", + } + section_header(r, k, extra) + else + error(string.format("unexpected type %s", _t)) + end +end + +local function encode_header_as_source(k, v, boundary, ctx) + local r = {} + encode_header_to_table(r, k, v, boundary, ctx) + local s = table.concat(r) + if ctx then + ctx.headers_length = ctx.headers_length + #s + end + return ltn12.source.string(s) +end + +local function data_len(d) + local _t = type(d) + + if _t == "string" then + return string.len(d) + elseif _t == "table" then + if type(d.data) == "string" then + return string.len(d.data) + end + if d.len then return d.len end + error("must provide data length for non-string datatypes") + end +end + +local function content_length(t, boundary, ctx) + local r = ctx and ctx.headers_length or 0 + for k, v in pairs(t) do + if not ctx then + local tmp = {} + encode_header_to_table(tmp, k, v, boundary) + r = r + #table.concat(tmp) + end + r = r + data_len(v) + 2 -- `\r\n` + end + return r + #boundary + 6 -- `--BOUNDARY--\r\n` +end + +local function get_data_src(v) + local _t = type(v) + if v.source then + return v.source + elseif _t == "string" then + return ltn12.source.string(v) + elseif _t == "table" then + _t = type(v.data) + if _t == "string" then + return ltn12.source.string(v.data) + elseif _t == "table" then + return ltn12.source.table(v.data) + elseif _t == "userdata" then + return ltn12.source.file(v.data) + elseif _t == "function" then + return v.data + end + end + error("invalid input") +end + +local function set_ltn12_blksz(sz) + assert(type(sz) == "number", "set_ltn12_blksz expects a number") + ltn12.BLOCKSIZE = sz +end +mp.set_ltn12_blksz = set_ltn12_blksz + +local function source(t, boundary, ctx) + local sources, n = {}, 1 + for k, v in pairs(t) do + sources[n] = encode_header_as_source(k, v, boundary, ctx) + sources[n+1] = get_data_src(v) + sources[n+2] = ltn12.source.string("\r\n") + n = n + 3 + end + sources[n] = ltn12.source.string(string.format("--%s--\r\n", boundary)) + return ltn12.source.cat(table.unpack(sources)) +end +mp.source = source + +function mp.encode(t, boundary) + boundary = boundary or mp.boundary() + local r = {} + assert(ltn12.pump.all( + (source(t, boundary)), + (ltn12.sink.table(r)) + )) + return table.concat(r), boundary +end + +return mp diff --git a/readme.org b/readme.org new file mode 100644 index 0000000..37ce50f --- /dev/null +++ b/readme.org @@ -0,0 +1,29 @@ +* Telegram API + +This is bindings of Telegram API on Lua, part of [[https://gitdab.com/er2/comp-tg][my bot]]. + +* Installation + +We recommended to use *.gitmodules* file for that. +To include, add to gitmodules this: + +#+begin_src +[submodule "tg-api"] + path = # enter your path + url = https://gitdab.com/er2/tg-api-lua + +#+end_src + +Also you need to: + + Install *LuaSec* for https requests. + + + Execute this code *before* include this API: + + #+begin_src lua + package.path = 'yourpath/?.lua;yourpath/?/init.lua;' .. package.path + #+end_src + + + Copy to yourpath +/api+ files *event.lua* and *json.lua* + + They can be taken from [[https://gitdab.com/er2/comp-tg/src/branch/main/etc][here]]. + diff --git a/tools.lua b/tools.lua new file mode 100644 index 0000000..54a9801 --- /dev/null +++ b/tools.lua @@ -0,0 +1,103 @@ +--[[ Additional tools + -- (c) Er2 2021 + -- Zlib license +--]] + +local tools = { + json = require 'json', +} + +local json = tools.json +local https = require 'ssl.https' +local ltn12 = require 'ltn12' +local mp = require 'api.multipart' + +function tools.fetchCmd(text) + return + text:match '/([%w_]+)', -- cmd + text:match '/[%w_]+@([%w_]+)' -- to +end + +function tools._req(url, meth, data, ctype) + assert(url, 'Provide URL!') + assert(meth, 'Provide method!') + + local resp = {} + local head = { + url = url, + method = meth, + headers = { + ['Content-Type'] = ctype, + ['Content-Length'] = #(data or ''), + }, + source = ltn12.source.string(data), + sink = ltn12.sink.table(resp), + } + + local succ, res = https.request(head) + if not succ then + print('Connection error [' .. res .. ']') + return nil, false + end + return resp[1], true +end + +function tools.preq(url, par) + par = par or {} + + local body, bound = mp.encode(par) + return tools._req( + url, + 'POST', + body, + 'multipart/form-data; boundary=' .. bound + ) +end + +function tools.greq(url, par, f) + par = json.encode(par) + return tools._req(url, 'GET', par, 'application/json') +end + +function tools.req(url, par, f, dbg) + local res, succ + par = par or {} + + -- files + if f and next(f) ~= nil then + par = par or {} + for k, v in pairs(par) do par[k] = tostring(v) end + local ft, fn = next(f) + local fr = io.open(fn, 'r') + if fr then + par[ft] = { + filename = fn, + data = fr:read '*a' + } + fr:close() + else par[ft] = fn end + res, succ = tools.preq(url, par) + else -- text + res, succ = tools.greq(url, par) + end + + if dbg then print(url, succ, res, par) + -- dump(par)) + end + res = json.decode(res or '{}') + if not succ or not res then return {}, false end + return res, true +end + +function tools.request(token, endpoint, param, f) + assert(token, 'Provide token!') + assert(endpoint, 'Provide endpoint!') + + local url = 'https://api.telegram.org/bot' ..token.. '/' ..endpoint + + dbg = true + local resp = tools.req(url, param, f, dbg) + return resp, resp.ok or false +end + +return tools