commit 30f7203c5fe69e875dbd29a4da9fe0c5ab1ddc9b Author: Er2 Date: Mon Aug 16 11:47:49 2021 +0300 move from bot 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