├── .busted ├── matrix.lua ├── examples ├── api-send-message.lua ├── client-send-message.lua ├── set-display-name.lua ├── get-user-info.lua ├── echobot.lua └── client-cqchat.lua ├── .travis.yml ├── spec ├── detailUtfTerm.lua ├── client_room_spec.lua └── eventable_spec.lua ├── matrix ├── httpclient │ ├── chttp.lua │ └── luasocket.lua ├── eventable.lua ├── api.lua └── client.lua └── README.md /.busted: -------------------------------------------------------------------------------- 1 | -- vim:set ft=lua: 2 | return { 3 | _all = { 4 | verbose = true, 5 | output = "spec.detailUtfTerm", 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /matrix.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- matrix.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | return require "matrix.client" 9 | -------------------------------------------------------------------------------- /examples/api-send-message.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- api-send-message.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | if #arg ~= 4 then 10 | io.stderr:write(string.format("Usage: %s \n", arg[0])) 11 | os.exit(1) 12 | end 13 | 14 | local api = require "matrix" .api(arg[1]) 15 | local response = api:login("m.login.password", { user = arg[2], password = arg[3] }) 16 | api.token = response.access_token 17 | api:send_message(arg[4], io.read("*a")) 18 | api:logout() 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | env: 5 | matrix: 6 | - LUA="lua 5.1" 7 | - LUA="lua 5.2" 8 | - LUA="lua 5.3" 9 | - LUA="luajit 2.0" 10 | - LUA="luajit 2.1" 11 | 12 | before_install: 13 | - pip install hererocks 14 | - hererocks here -r^ --$LUA 15 | - export PATH=$PATH:$PWD/here/bin 16 | - eval `luarocks path --bin` 17 | - lua -v 18 | 19 | install: 20 | - luarocks install lua-cjson 21 | - luarocks install luacov-coveralls 22 | - luarocks install cluacov 23 | - luarocks install busted 24 | 25 | script: 26 | - busted -c 27 | 28 | after_success: 29 | - luacov-coveralls -i matrix/ -e spec/ -e here/ 30 | -------------------------------------------------------------------------------- /examples/client-send-message.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- send-message.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | if #arg ~= 4 then 10 | io.stderr:write(string.format("Usage: %s \n", arg[0])) 11 | os.exit(1) 12 | end 13 | 14 | local client = require "matrix" .client(arg[1]) 15 | 16 | -- Passing "true" as last parameter skips the initial sync, which can be slow 17 | client:login_with_password(arg[2], arg[3], true) 18 | 19 | local room = client:join_room(arg[4]) 20 | room:send_text(io.read("*a")) 21 | 22 | client:logout() 23 | -------------------------------------------------------------------------------- /examples/set-display-name.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- get-user-info.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | if #arg ~= 4 then 10 | io.stderr:write(string.format("Usage: %s \n", arg[0])) 11 | os.exit(1) 12 | end 13 | 14 | local api = require "matrix" .api(arg[1]) 15 | 16 | -- Login and configure the access token used for further API requests 17 | local response = api:login("m.login.password", { user = arg[2], password = arg[3] }) 18 | api.token = response.access_token 19 | 20 | -- Set the display name 21 | api:set_display_name(response.user_id, arg[4]) 22 | api:logout() 23 | -------------------------------------------------------------------------------- /examples/get-user-info.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- get-user-info.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | if #arg ~= 3 then 10 | io.stderr:write(string.format("Usage: %s \n", arg[0])) 11 | os.exit(1) 12 | end 13 | 14 | local client = require "matrix" .client(arg[1]) 15 | client:login_with_password(arg[2], arg[3]) 16 | 17 | local user = client:get_user() 18 | print("User ID: " .. user.user_id) 19 | 20 | user:hook("property-changed", function (user, property) 21 | print(" - " .. property .. ": " .. tostring(user[property])) 22 | end) 23 | user:update_display_name() 24 | user:update_avatar_url() 25 | 26 | client:logout() 27 | -------------------------------------------------------------------------------- /spec/detailUtfTerm.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- detailUtfTerm.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local colors = require 'term.colors' 10 | 11 | return function(options) 12 | local busted = require 'busted' 13 | local handler = require 'busted.outputHandlers.utfTerminal' (options) 14 | 15 | handler.fileStart = function(element) 16 | io.write("\n" .. colors.cyan(handler.getFullName(element)) .. ':') 17 | end 18 | 19 | handler.testStart = function(element, parent, status, debug) 20 | local name = handler.getFullName(element) 21 | local len = #name 22 | if len > 72 then 23 | name = name:sub(1, 72) .. colors.white(" […] ") 24 | io.write("\n " .. name) 25 | else 26 | len = len + 2 27 | io.write('\n ' .. name .. " ") 28 | for i = 1, 78 - len - 1 do 29 | io.write(colors.white('·')) 30 | end 31 | io.write(" ") 32 | end 33 | io.flush() 34 | end 35 | 36 | busted.subscribe({ 'file', 'start' }, handler.fileStart) 37 | busted.subscribe({ 'test', 'start' }, handler.testStart) 38 | 39 | return handler 40 | end 41 | -------------------------------------------------------------------------------- /matrix/httpclient/chttp.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- chttp.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local request = require "http.request" 10 | local headers = require "http.headers" 11 | local dict_to_query = require "http.util" .dict_to_query 12 | 13 | local httpclient = { 14 | quote = require "http.util" .encodeURI, 15 | unquote = require "http.util" .decodeURI, 16 | } 17 | httpclient.__name = "matrix.client.chttp" 18 | httpclient.__index = httpclient 19 | 20 | function httpclient:__tostring() 21 | return self.__name 22 | end 23 | 24 | local function headers_to_dict(h) 25 | local headers = {} 26 | for name, value in h:each() do 27 | if name:sub(1, 1) ~= ":" then 28 | headers[name] = value 29 | end 30 | end 31 | return headers 32 | end 33 | 34 | function httpclient:request(log, method, url, query_args, body, headers) 35 | do 36 | local qs = dict_to_query(query_args) 37 | if #qs > 0 then 38 | url = url .. "?" .. qs 39 | end 40 | end 41 | 42 | log(">~> %s %s", method, url) 43 | log(">>> %s", body) 44 | 45 | local req = request.new_from_uri(url) 46 | for name, value in pairs(headers) do 47 | req.headers:append(name, value) 48 | end 49 | req.headers:upsert(":method", method) 50 | if body then 51 | req:set_body(body) 52 | end 53 | local h, s = req:go() 54 | if not h then 55 | log(" 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local matrix = require "matrix.client" 10 | 11 | local A_ROOM_ID = "!crFlAIxFGhReTaXtra:local" 12 | 13 | describe("matrix.room", function () 14 | 15 | describe("__call metamethod", function () 16 | it("instantiates", function () 17 | local room = matrix.room({}, A_ROOM_ID) 18 | assert.is_not_equal(matrix.room, room) 19 | assert.is_equal(matrix.room, getmetatable(room)) 20 | end) 21 | it("sets .room_id", function () 22 | local dummy = {} 23 | local room = matrix.room(dummy, A_ROOM_ID) 24 | assert.is_equal(A_ROOM_ID, room.room_id) 25 | end) 26 | it("sets .client", function () 27 | local dummy = {} 28 | local room = matrix.room(dummy, A_ROOM_ID) 29 | assert.is_equal(dummy, room.client) 30 | end) 31 | it("sets .aliases", function () 32 | local room = matrix.room({}, A_ROOM_ID) 33 | assert.is_table(room.aliases) 34 | end) 35 | it("sets .members", function () 36 | local room = matrix.room({}, A_ROOM_ID) 37 | assert.is_table(room.members) 38 | end) 39 | end) 40 | 41 | describe("__eq metamethod", function () 42 | it("compares using .room_id", function () 43 | local r1 = matrix.room({}, A_ROOM_ID) 44 | local r2 = matrix.room({}, A_ROOM_ID) 45 | assert.are_not_same(r1, r2) 46 | assert.are_equal(r1, r2) 47 | local r3 = matrix.room({}, "!someotherid:local") 48 | assert.are_not_same(r1, r3) 49 | assert.are_not_same(r2, r3) 50 | assert.are_not_equal(r1, r3) 51 | assert.are_not_equal(r2, r3) 52 | end) 53 | end) 54 | 55 | end) 56 | -------------------------------------------------------------------------------- /matrix/httpclient/luasocket.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- luasocket.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local urlescape = require "socket.url" .escape 10 | local stringsource = require "ltn12" .source.string 11 | local tablesink = require "ltn12" .sink.table 12 | 13 | local request_https = function (...) 14 | request_https = require "ssl.https" .request 15 | return request_https(...) 16 | end 17 | 18 | local request_http = function (...) 19 | request_http = require "socket.http" .request 20 | return request_http(...) 21 | end 22 | 23 | local function make_request(t) 24 | if t.url:sub(1, #"https://") == "https://" then 25 | return request_https(t) 26 | else 27 | return request_http(t) 28 | end 29 | end 30 | 31 | local function dict_to_query(d) 32 | local r, i = {}, 0 33 | for name, value in pairs(d) do 34 | i = i + 1 35 | r[i] = urlescape(name) .. "=" .. urlescape(value) 36 | end 37 | return table.concat(r, "&", 1, i) 38 | end 39 | 40 | local httpclient = { 41 | quote = require "socket.url" .escape, 42 | unquote = require "socket.url" .unescape, 43 | } 44 | httpclient.__name = "matrix.client.luasocket" 45 | httpclient.__index = httpclient 46 | 47 | function httpclient:__tostring() 48 | return self.__name 49 | end 50 | 51 | function httpclient:request(log, method, url, query_args, body, headers) 52 | do 53 | local qs = dict_to_query(query_args) 54 | if #qs > 0 then 55 | url = url .. "?" .. qs 56 | end 57 | end 58 | 59 | log(">~> %s %s", method, url) 60 | log(">>> %s", body) 61 | 62 | local source 63 | if body and #body > 0 then 64 | headers["content-length"] = #body 65 | source = stringsource(body) 66 | end 67 | local result = {} 68 | local r, c, h, statusline = make_request { 69 | url = url, 70 | method = method, 71 | headers = headers, 72 | source = source, 73 | sink = tablesink(result), 74 | } 75 | local response = table.concat(result) 76 | 77 | if not (r and statusline) then 78 | local message = #response > 0 and response or "unknown error" 79 | log(" 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local function eprintf(fmt, ...) 10 | io.stderr:write(fmt:format(...)) 11 | io.stderr:flush() 12 | end 13 | 14 | if #arg ~= 3 then 15 | eprintf("Usage: %s \n", arg[0]) 16 | os.exit(1) 17 | end 18 | 19 | local client = require "matrix" .client(arg[1]) 20 | local running, start_ts = true, os.time() * 1000 21 | 22 | client:hook("invite", function (client, room) 23 | -- When invited to a room, join it 24 | eprintf("Invited to room %s\n", room) 25 | client:join_room(room) 26 | end):hook("logged-in", function (client) 27 | eprintf("Logged in successfully\n") 28 | end):hook("logged-out", function (client) 29 | eprintf("Logged out... bye!\n") 30 | end):hook("left", function (client, room) 31 | eprintf("Left room %s, active rooms:\n", room) 32 | for room_id, room in pairs(client.rooms) do 33 | assert(room_id == room.room_id) 34 | eprintf(" - %s\n", room) 35 | end 36 | end):hook("joined", function (client, room) 37 | eprintf("Active rooms:\n") 38 | for room_id, room in pairs(client.rooms) do 39 | assert(room_id == room.room_id) 40 | eprintf(" - %s\n", room) 41 | end 42 | 43 | room:send_text("Type “!echobot go bananas” to make the bot exit") 44 | room:send_text("Type “!echobot leave the room” to make the bot leave the room") 45 | 46 | room:hook("message", function (room, sender, message, event) 47 | if event.origin_server_ts < start_ts then 48 | eprintf("%s: (Skipping message sent before bot startup)\n", room) 49 | return 50 | end 51 | if sender == room.client.user_id then 52 | eprintf("%s: (Skipping message sent by ourselves)\n", room) 53 | return 54 | end 55 | if message.msgtype ~= "m.text" then 56 | eprintf("%s: (Message of type %s ignored)\n", room, message.msgtype) 57 | return 58 | end 59 | 60 | eprintf("%s: <%s> %s\n", room, sender, message.body) 61 | 62 | if message.body == "!echobot leave the room" then 63 | room:send_text("(leaving the room as requested)") 64 | room:leave() 65 | elseif message.body == "!echobot go bananas" then 66 | for _, room in pairs(client.rooms) do 67 | room:send_text("(gracefully shutting down)") 68 | end 69 | running = false 70 | else 71 | -- Echo! That's what echobot does! 72 | room:send_text(message.body) 73 | end 74 | end) 75 | end) 76 | 77 | client:login_with_password(arg[2], arg[3]) 78 | client:sync(function () return not running end) 79 | client:logout() 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Matrix Client-Server API for Lua 2 | ================================ 3 | 4 | [![Build Status](https://travis-ci.org/aperezdc/lua-matrix.svg?branch=master)](https://travis-ci.org/aperezdc/lua-matrix) 5 | [![Coverage Status](https://coveralls.io/repos/github/aperezdc/lua-matrix/badge.svg?branch=master)](https://coveralls.io/github/aperezdc/lua-matrix?branch=master) 6 | 7 | This is closely modelled after the official 8 | [matrix-python-sdk](https://github.com/matrix-org/matrix-python-sdk). 9 | 10 | 11 | Requirements 12 | ------------ 13 | 14 | * Lua 5.1, 5.2, 5.3, or LuaJIT — development and testing is only being done 15 | with 5.3, YMMV! 16 | * The [cjson](http://www.kyne.com.au/~mark/software/lua-cjson.php) module. 17 | * One of the supported HTTP client libraries: 18 | - Daurnimator's excellent, 19 | [cqueues](http://25thandclement.com/~william/projects/cqueues.html)-based 20 | [http](https://github.com/daurnimator/lua-http) module. 21 | - [LuaSocket](http://w3.impa.br/~diego/software/luasocket) and (optionally) 22 | [LuaSec](https://github.com/brunoos/luasec) for TLS support. 23 | 24 | If you use [LuaRocks](https://luarocks.org), you can get the dependencies 25 | installed using the following commands: 26 | 27 | ```sh 28 | luarocks install --server=http://luarocks.org/dev http 29 | luarocks install lua-cjson 30 | ``` 31 | 32 | Self-promotion bit: If you use the [Z shell](http://www.zsh.org/) and want 33 | something like 34 | [virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) for Lua, 35 | please *do try* [RockZ](https://github.com/aperezdc/rockz). 36 | 37 | 38 | Usage 39 | ----- 40 | 41 | The library provides two levels of abstraction. The low-level layer wraps the 42 | raw HTTP API. The high-level layer wraps the low-level layer and provides an 43 | object model to perform actions on. 44 | 45 | High-level `matrix.client` interface: 46 | 47 | ```lua 48 | local client = require("matrix").client("http://localhost:8008") 49 | local token = client:register_with_password("jdoe", "sup3rsecr1t") 50 | local room = client:create_room("my_room_alias") 51 | room:send_text("Hello!") 52 | ``` 53 | 54 | Low-level `matrix.api` interface: 55 | 56 | ```lua 57 | local matrix = require("matrix") 58 | local api = matrix.api("http://localhost:8080") 59 | local response = api:register("m.login.password", 60 | { user = "jdoe", password = "sup3rsecr1t" }) 61 | api.token = response.token 62 | handle_events(api:sync()) 63 | response = api:create_room({ alias = "my_room_alias" }) 64 | api:send_text(response.room_id, "Hello!") 65 | ``` 66 | 67 | ### More Examples 68 | 69 | For the low-level `matrix.api`: 70 | 71 | * [examples/set-display-name.lua](./examples/set-display-name.lua) 72 | * [examples/api-send-message.lua](./examples/api-send-message.lua) 73 | 74 | For the high-level `matrix.client`: 75 | 76 | * [examples/get-user-info.lua](./examples/get-user-info.lua) 77 | * [examples/client-send-message.lua](./examples/client-send-message.lua) 78 | * [examples/echobot.lua](./examples/echobot.lua) 79 | 80 | More examples can be found in the [examples](./examples) subdirectory. 81 | -------------------------------------------------------------------------------- /matrix/eventable.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- eventable.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- Distributed under terms of the MIT license. 6 | -- 7 | 8 | local _select, _pack, _unpack = select, table.pack, table.unpack or unpack 9 | 10 | if not _pack then 11 | -- Provide a table.pack() implementation for Lua 5.1 and LuaJIT. 12 | _pack = function (...) 13 | local n = _select("#", ...) 14 | local r = { n = n } 15 | for i = 1, n do 16 | r[i] = _select(i, ...) 17 | end 18 | return r 19 | end 20 | end 21 | 22 | local log = (function () 23 | local env_value = os.getenv("MATRIX_EVENTABLE_DEBUG_LOG") 24 | if env_value and #env_value > 0 and env_value ~= "0" then 25 | local out, _tostring = io.stderr, tostring 26 | return function (...) 27 | out:write("[eventable]") 28 | local n = _select("#", ...) 29 | for i = 1, n do 30 | out:write(" " .. _tostring(_select(i, ...))) 31 | end 32 | out:write("\n") 33 | out:flush() 34 | end 35 | else 36 | return function (...) end 37 | end 38 | end)() 39 | 40 | local function do_hook(event_map, event, handler) 41 | log("hook:", event, handler) 42 | if handler then 43 | if not event_map[event] then 44 | event_map[event] = {} 45 | end 46 | local handlers = event_map[event] 47 | handlers[#handlers + 1] = handler 48 | else 49 | return event_map[event] 50 | end 51 | end 52 | 53 | local function do_unhook(event_map, event, handler) 54 | log("unhook:", event, handler) 55 | if handler == nil then 56 | event_map[event] = nil 57 | return 58 | end 59 | local old_handlers = event_map[event] 60 | if old_handlers then 61 | local handlers = {} 62 | for i = 1, #old_handlers do 63 | local h = old_handlers[i] 64 | if h ~= handler then 65 | handlers[#handlers + 1] = h 66 | end 67 | end 68 | event_map[event] = handlers 69 | end 70 | end 71 | 72 | local function do_fire(event_map, event, ...) 73 | log("fire: " .. event .. ":", ...) 74 | local handlers = event_map[event] 75 | if handlers then 76 | for i = 1, #handlers do 77 | local ret = _pack(handlers[i](...)) 78 | if ret.n > 0 then 79 | return _unpack(ret) 80 | end 81 | end 82 | end 83 | end 84 | 85 | local function eventable_functions (event_map) 86 | if not event_map then event_map = {} end 87 | return function (e, ...) return do_fire(event_map, e, ...) end, 88 | function (e, h) return do_hook(event_map, e, h) end, 89 | function (e, h) return do_unhook(event_map, e, h) end 90 | end 91 | 92 | local function eventable_object(obj, event_map) 93 | if not obj then obj = {} end 94 | if not event_map then event_map = {} end 95 | function obj:fire(e, ...) 96 | return do_fire(event_map, e, self, ...) 97 | end 98 | function obj:hook(...) 99 | do_hook(event_map, ...) 100 | return self -- Allow chaining 101 | end 102 | function obj:unhook(...) 103 | do_unhook(event_map, ...) 104 | return self -- Allow chaining 105 | end 106 | return obj 107 | end 108 | 109 | return { 110 | functions = eventable_functions, 111 | object = eventable_object, 112 | } 113 | -------------------------------------------------------------------------------- /spec/eventable_spec.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- eventable.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- Distributed under terms of the MIT license. 6 | -- 7 | 8 | local eventable = require "matrix.eventable" 9 | 10 | describe("matrix.eventable", function () 11 | it("can be imported", function () 12 | assert.is.table(eventable) 13 | end) 14 | 15 | it("has a matrix.eventable.functions function", function () 16 | assert.is_function(eventable.functions) 17 | end) 18 | 19 | it("has a matrix.eventable.object function", function () 20 | assert.is_function(eventable.object) 21 | end) 22 | end) 23 | 24 | describe("matrix.eventable.functions()", function () 25 | it("returns three callable functions", function () 26 | local s = spy.new(eventable.functions) 27 | local fire, hook, unhook = s() 28 | assert.spy(s).returned_with( 29 | match.is_function(), 30 | match.is_function(), 31 | match.is_function()) 32 | end) 33 | 34 | it("calls hooked handlers when firing events", function () 35 | local flag = false 36 | local handler = spy.new(function () flag = true end) 37 | 38 | local fire, hook = assert(eventable.functions()) 39 | hook("foo", handler) 40 | fire("foo") 41 | 42 | assert.spy(handler).was_called(1) 43 | assert.is_true(flag) 44 | end) 45 | 46 | it("stops at first handler that returs some value", function () 47 | local fire, hook = assert(eventable.functions()) 48 | local flag1, flag2, flag3 = false, false, false 49 | hook("foo", function () flag1 = true end) 50 | hook("foo", function () flag2 = true ; return 42 end) 51 | hook("foo", function () flag3 = true ; return 0 end) 52 | assert.is_equal(42, fire("foo")) 53 | assert.is_true(flag1) 54 | assert.is_true(flag2) 55 | assert.is_false(flag3) 56 | end) 57 | 58 | it("accepts a table where to store the event map", function () 59 | local events = {} 60 | local fire, hook = assert(eventable.functions(events)) 61 | hook("foo", function () end) 62 | assert.truthy(events.foo) 63 | end) 64 | 65 | it("allows unhooking a handler", function () 66 | local fire, hook, unhook = assert(eventable.functions()) 67 | 68 | local h1 = spy.new(function () end) 69 | local h2 = spy.new(function () end) 70 | hook("foo", h1) 71 | hook("foo", h2) 72 | fire("foo") 73 | assert.spy(h1).was_called(1) 74 | assert.spy(h2).was_called(1) 75 | 76 | unhook("foo", h2) 77 | fire("foo") 78 | assert.spy(h1).was_called(2) 79 | assert.spy(h2).was_called(1) 80 | end) 81 | 82 | it("allows unhooking all handlers at once", function () 83 | local fire, hook, unhook = assert(eventable.functions()) 84 | 85 | local h1 = spy.new(function () end) 86 | local h2 = spy.new(function () end) 87 | hook("foo", h1) 88 | hook("foo", h2) 89 | fire("foo") 90 | assert.spy(h1).was_called(1) 91 | assert.spy(h2).was_called(1) 92 | 93 | unhook("foo") 94 | fire("foo") 95 | assert.spy(h1).was_called(1) 96 | assert.spy(h2).was_called(1) 97 | end) 98 | 99 | it("allows retrieving the list of handlers", function () 100 | local fire, hook = assert(eventable.functions()) 101 | local h = function () end 102 | hook("foo", h) 103 | assert.same({ h }, hook("foo")) 104 | hook("foo", h) 105 | assert.same({ h, h }, hook("foo")) 106 | end) 107 | 108 | it("unhooks multiple instances of the same handler", function () 109 | local fire, hook, unhook = assert(eventable.functions()) 110 | local h = function () end 111 | hook("foo", h) 112 | hook("foo", h) 113 | assert.same({ h, h }, hook("foo")) 114 | unhook("foo", h) 115 | assert.same({}, hook("foo")) 116 | end) 117 | end) 118 | 119 | describe("matrix.eventable.object()", function () 120 | it("creates a new table when no parameters are passed", function () 121 | assert.is_table(eventable.object()) 122 | end) 123 | 124 | it("adds methods to an existing table", function () 125 | local t = {} 126 | assert.is_equal(t, eventable.object(t)) 127 | assert.is_function(t.fire) 128 | assert.is_function(t.hook) 129 | assert.is_function(t.unhook) 130 | end) 131 | 132 | it("passes the table as first argument when firing events", function () 133 | local t = assert(eventable.object()) 134 | local h = spy.new(function (o) 135 | assert.is_equal(t, o) 136 | end) 137 | t:hook("foo", h) 138 | t:fire("foo") 139 | assert.spy(h).was_called_with(t) 140 | end) 141 | 142 | it("allows chaining :hook() and :unhook() calls", function () 143 | local t = assert(eventable.object()) 144 | local h = function () end 145 | assert.is_equal(t, t:hook("foo", h)) 146 | assert.is_equal(t, t:unhook("foo", h)) 147 | assert.is_equal(t, t:hook("foo", h):unhook("foo", h)) 148 | end) 149 | end) 150 | -------------------------------------------------------------------------------- /examples/client-cqchat.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- client-cqchat.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local cqueues = require "cqueues" 10 | local posix = require "posix" 11 | local bit = require "bit32" 12 | local matrix = require "matrix" 13 | 14 | local function xpcall_traceback(errmsg) 15 | local tb = debug.traceback(nil, nil, 2) 16 | return errmsg and (errmsg .. "\n" .. tb) or tb 17 | end 18 | 19 | -- 20 | -- Wraps the controlling terminal into an object which can be polled 21 | -- with cqueues.poll() and uses O_NONBLOCK for input. 22 | -- 23 | local tty = { 24 | file = (function () 25 | local f = io.open(posix.ctermid(), "a+") 26 | local fd = posix.fileno(f) 27 | local flags = posix.fcntl(fd, posix.F_GETFL, 0) 28 | flags = bit.bor(flags, assert(posix.O_NONBLOCK)) 29 | if posix.O_CLOEXEC then 30 | flags = bit.bor(flags, posix.O_CLOEXEC) 31 | else 32 | io.stderr:write("no O_CLOEXEC, the TTY file descriptor might leak") 33 | io.stderr:flush() 34 | end 35 | if posix.fcntl(fd, posix.F_SETFL, flags) ~= 0 then 36 | error("cannot set O_NONBLOCK/O_CLOEXEC: " .. posix.errno()) 37 | end 38 | return f 39 | end)(), 40 | 41 | -- Functions expected by cqueues 42 | pollfd = function (self) return posix.fileno(self.file) end, 43 | events = function () return "r" end, -- Read only 44 | timeout = function () return nil end, -- Set in the call to cqueues.poll() 45 | 46 | -- Wrappers for tcsetattr/tcgetattr 47 | tcgetattr = function (self) return posix.tcgetattr(self:pollfd()) end, 48 | tcsetattr = function (self, ...) return posix.tcsetattr(self:pollfd(), ...) end, 49 | 50 | -- Obtain the terminal size using TIOCGWINSZ 51 | _size_width = false, 52 | _size_height = false, 53 | 54 | size = function (self, force) 55 | if force then 56 | self._size_width = false 57 | self._size_height = false 58 | end 59 | if self._size_width == false then 60 | local p = io.popen("tput cols", "r") 61 | self._size_width = p:read("*n") 62 | p:close() 63 | end 64 | if self._size_height == false then 65 | local p = io.popen("tput lines", "r") 66 | self._size_height = p:read("*n") 67 | p:close() 68 | end 69 | return self._size_width, self._size_height 70 | end, 71 | 72 | -- Saves terminal attributes, runs a function, and restores attributes 73 | -- even if the function raises an error. 74 | wrap = function (self, f, ...) 75 | local saved_attr = self:tcgetattr() 76 | local ok, err = xpcall(f, xpcall_traceback, self, ...) 77 | self:tcsetattr(posix.TCSANOW, saved_attr) 78 | if not ok then 79 | error("tty:wrap: Error in wrapped function:\n" .. err) 80 | end 81 | return self 82 | end 83 | } 84 | 85 | local command_pattern = "^%s*/(%a+)%s+(.*)$" 86 | 87 | local function main(tty, client, username, password) 88 | do 89 | local a = tty:tcgetattr() 90 | a.cc[posix.VMIN] = 1 91 | a.cc[posix.VTIME] = 0 92 | a.lflag = bit.band(a.lflag, bit.bnot(bit.bor(posix.ECHO, posix.ICANON))) 93 | a.iflag = bit.band(a.iflag, bit.bnot(bit.bor(posix.IXON, posix.ISTRIP))) 94 | a.cflag = bit.band(a.cflag, bit.bnot(bit.bor(posix.CSIZE, posix.PARENB))) 95 | a.cflag = bit.bor(a.cflag, posix.CS8) 96 | a.oflag = bit.band(a.oflag, bit.bnot(posix.OPOST)) 97 | if tty:tcsetattr(posix.TCSANOW, a) ~= 0 then 98 | error("tcsetattr: " .. posix.errno()) 99 | end 100 | end 101 | 102 | local cq = cqueues.new() 103 | local ok, err, obj = cq:wrap(function () 104 | client:login_with_password(username, password) 105 | 106 | local running = true 107 | local client_should_stop = function () return not running end 108 | local clientqueue = cq:wrap(function () 109 | client:sync(client_should_stop) 110 | end) 111 | 112 | local current_room 113 | while running do 114 | local line = "" 115 | while true do 116 | io.stdout:write(string.format("\r[%s] %s", 117 | current_room and current_room:get_alias_or_id() or "*", 118 | line)) 119 | io.stdout:flush() 120 | 121 | local handle_tty = false 122 | cqueues.poll(tty, 0.05) 123 | 124 | local ch = tty.file:read(1) 125 | if ch then 126 | if ch == "\4" and #line == 0 then 127 | print("\r") 128 | running = false 129 | return 130 | elseif ch == "\127" then 131 | line = line:sub(1, -2) 132 | elseif ch == "\n" then 133 | break 134 | else 135 | line = line .. ch 136 | end 137 | end 138 | end 139 | 140 | local command, params = line:match(command_pattern) 141 | if command then 142 | if command == "room" then 143 | if client.rooms[params] then 144 | current_room = client.rooms[params] 145 | else 146 | print("\r/!\\ No such room") 147 | end 148 | end 149 | else 150 | if current_room then 151 | current_room:send_text(line) 152 | else 153 | print("\r/!\\ Choose a room using '/room '") 154 | end 155 | end 156 | end 157 | end):loop() 158 | if not ok then 159 | error(err) 160 | end 161 | end 162 | 163 | local function print_room_message(room, sender, message, event) 164 | print(string.format("\rK[%s] <%s> %s", room.room_id, sender, message.body)) 165 | end 166 | 167 | if #arg ~= 3 then 168 | io.stderr:write(string.format("Usage: %s \n", arg[0])) 169 | os.exit(1) 170 | end 171 | 172 | -- 173 | -- Force usage of "chttp", the cqueues-based HTTP client library 174 | -- 175 | local client = matrix.client(arg[1], nil, "chttp") 176 | :hook("logged-in", function (client) 177 | print("\r * Logged in as " .. client.user_id) 178 | end) 179 | :hook("joined", function (client, room) 180 | room:update_aliases() 181 | local extra = "" 182 | if #room.aliases > 0 then 183 | extra = " (" .. table.concat(room.aliases, ", ") .. ")" 184 | end 185 | print("\r * Joined room " .. room.room_id .. extra) 186 | room:hook("message", print_room_message) 187 | end) 188 | :hook("left", function (client, room) 189 | print("\r * Left room " .. room.room_id) 190 | end) 191 | 192 | tty:wrap(main, client, arg[2], arg[3]) 193 | -------------------------------------------------------------------------------- /matrix/api.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- api.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local json = require "cjson" 10 | 11 | local function noprintf(...) end 12 | local function eprintf(fmt, ...) 13 | io.stderr:write("[api] ") 14 | io.stderr:write(fmt:format(...)) 15 | io.stderr:write("\n") 16 | io.stderr:flush() 17 | end 18 | 19 | local function get_debug_log_function() 20 | local env_value = os.getenv("MATRIX_API_DEBUG_LOG") 21 | if env_value and #env_value > 0 and env_value ~= "0" then 22 | return eprintf 23 | else 24 | return noprintf 25 | end 26 | end 27 | 28 | local get_http_client = function (http_client) 29 | -- The environment variable has precedence, as it is used to aid debugging. 30 | do 31 | local env_value = os.getenv("MATRIX_API_HTTP_CLIENT") 32 | if env_value and #env_value > 0 then 33 | http_client = env_value 34 | end 35 | end 36 | -- Try to import supplied HTTP client libraries, in order of preference. 37 | local tries = http_client and { http_client } or { "chttp", "luasocket" } 38 | local errors = {} 39 | for i, http_client in ipairs(tries) do 40 | local ok, client = pcall(require, "matrix.httpclient." .. http_client) 41 | if ok then 42 | get_http_client = function () return client end 43 | return get_http_client() 44 | end 45 | errors[i] = client 46 | end 47 | local errmsg = { "Could not load any HTTP client library:" } 48 | for i, name in pairs(tries) do 49 | errmsg[#errmsg + 1] = "--- Loading '" .. name .. "'" 50 | errmsg[#errmsg + 1] = errors[i] 51 | end 52 | error(table.concat(errmsg, "\n")) 53 | end 54 | 55 | 56 | local API = {} 57 | API.__name = "matrix.api" 58 | API.__index = API 59 | 60 | setmetatable(API, { __call = function (self, base_url, token, http_client) 61 | return setmetatable({ 62 | base_url = base_url, 63 | token = token, 64 | txn_id = 0, 65 | api_path = "/_matrix/client/r0", -- TODO: De-hardcode 66 | _log = get_debug_log_function(), 67 | _http = get_http_client(http_client)(), 68 | }, API) 69 | end }) 70 | 71 | function API:__tostring() 72 | return self.__name .. "{" .. self.base_url .. "}" 73 | end 74 | 75 | ---- 76 | -- | Option | Type | Default Value | 77 | -- |:=========|:========|===============| 78 | -- | filter | string | nil | 79 | -- | since | string | nil | 80 | -- | full | boolean | false | 81 | -- | online | boolean | true | 82 | -- | timeout | number | nil | 83 | ---- 84 | function API:sync(options) 85 | local params 86 | if options then 87 | params = { 88 | filter = options.filter, 89 | since = options.since, 90 | full_state = options.full or false, 91 | set_presence = online and nil or "offline", 92 | timeout = options.timeout, 93 | } 94 | end 95 | return self:_send("GET", "/sync", params) 96 | end 97 | 98 | function API:register(login_type, params) 99 | return self:_send_with_params("POST", "/register", nil, 100 | { type = login_type }, params) 101 | end 102 | 103 | function API:login(login_type, params) 104 | return self:_send_with_params("POST", "/login", nil, 105 | { type = login_type }, params) 106 | end 107 | 108 | function API:logout() 109 | return self:_send("POST", "/logout") 110 | end 111 | 112 | function API:refresh_token(refresh_token) 113 | return self:_send("POST", "/tokenrefresh", nil, { refresh_token = refresh_token }) 114 | end 115 | 116 | function API:set_password(new_password, params) 117 | return self:_send_with_params("POST", "/account/password", 118 | { new_password = new_password }, params) 119 | end 120 | 121 | function API:get_3pids() 122 | local data = self:_send("GET", "/account/3pid") 123 | return data.threepids 124 | end 125 | 126 | function API:set_3pids(threepids, bind) 127 | return self:_send("POST", "/account/3pid", nil, 128 | { three_pid_creds = threepids, bind = (bind and true or false) }) 129 | end 130 | 131 | ---- 132 | -- | Option | Type | Default Value | 133 | -- |:==========|:=========|===============| 134 | -- | alias | string | nil | 135 | -- | public | boolean | false | 136 | -- | invite | {string} | {} | 137 | ---- 138 | function API:create_room(options) 139 | local params = { 140 | visibility = options.public and "public" or "private", 141 | room_alias_name = options.alias, 142 | invite = options.invite, 143 | } 144 | return self:_send("POST", "/createRoom", nil, params) 145 | end 146 | 147 | function API:join_room(room_id_or_alias) 148 | return self:_send("POST", "/join/" .. self._http.quote(room_id_or_alias)) 149 | end 150 | 151 | function API:event_stream(from_token, timeout) 152 | return self:_send("GET", "/events", { from = from_token, timeout = timeout or 30000 }) 153 | end 154 | 155 | function API:send_state_event(room_id, event_type, content, state_key) 156 | local path = "/rooms/" .. self._http.quote(room_id) .. 157 | "/state/" .. self._http.quote(event_type) 158 | if state_key then 159 | path = path .. "/" .. self._http.quote(state_key) 160 | end 161 | return self:_send("PUT", path, nil, content) 162 | end 163 | 164 | function API:send_message_event(room_id, event_type, content, txn_id) 165 | if not txn_id then 166 | txn_id = self.txn_id 167 | self.txn_id = self.txn_id + 1 168 | end 169 | local path = "/rooms/" .. self._http.quote(room_id) .. "/send/" .. 170 | self._http.quote(event_type) .. "/" .. 171 | self._http.quote(tostring(txn_id)) 172 | return self:_send("PUT", path, nil, content) 173 | end 174 | 175 | function API:send_content(room_id, item_url, item_name, msg_type, extra_info) 176 | return self:send_message_event(room_id, "m.room.message", 177 | { url = item_url, msgtype = msg_type, body = item_name, info = extra_info }) 178 | end 179 | 180 | function API:send_message(room_id, text_content, msg_type) 181 | return self:send_message_event(room_id, "m.room.message", 182 | self:get_text_body(text_content, msg_type or "m.text")) 183 | end 184 | 185 | function API:send_emote(room_id, text_content) 186 | return self:send_message_event(room_id, "m.room.message", 187 | self:get_emote_body(text_content)) 188 | end 189 | 190 | function API:send_notice(room_id, text_content) 191 | return self:send_message_event(room_id, "m.room.message", 192 | { msgtype = "m.notice", body = text_content }) 193 | end 194 | 195 | function API:get_room_name(room_id) 196 | return self:_send("GET", "/rooms/" .. self._http.quote(room_id) .. "/state/m.room.name") 197 | end 198 | 199 | function API:get_room_topic(room_id) 200 | return self:_send("GET", "/rooms/" .. self._http.quote(room_id) .. "/state/m.room.topic") 201 | end 202 | 203 | function API:leave_room(room_id) 204 | return self:_send("POST", "/rooms/" .. self._http.quote(room_id) .. "/leave") 205 | end 206 | 207 | function API:invite_user(room_id, user_id) 208 | return self:_send("POST", "/rooms/" .. self._http.quote(room_id) .. "/invite", nil, 209 | { user_id = user_id }) 210 | end 211 | 212 | function API:kick_user(room_id, user_id, reason) 213 | return self:set_membership(room_id, user_id, "leave", reason) 214 | end 215 | 216 | function API:set_membership(room_id, user_id, membership, reason) 217 | local path = "/rooms/" .. self._http.quote(room_id) .. 218 | "/state/m.room.member/" .. self._http.quote(user_id) 219 | return self:_send("PUT", path, nil, { membership = membership, reason = reason or "" }) 220 | end 221 | 222 | function API:ban_user(room_id, user_id, reason) 223 | return self:_send("POST", "/rooms/" .. self._http.quote(room_id) .. "/ban", nil, 224 | { user_id = user_id, reason = reason or "" }) 225 | end 226 | 227 | function API:get_room_state(room_id) 228 | return self:_send("GET", "/rooms/" .. self._http.quote(room_id) .. "/state") 229 | end 230 | 231 | function API:get_text_body(text, msg_type) 232 | return { msgtype = msg_type or "m.text", body = text } 233 | end 234 | 235 | function API:get_emote_body(text) 236 | return { msgtype = "m.emote", body = text } 237 | end 238 | 239 | function API:media_upload(content, content_type) 240 | -- TODO: De-harcode media API path 241 | return self:_send("POST", "", nil, content, 242 | { ["content-type"] = content_type }, 243 | "/_matrix/media/r0/upload") 244 | end 245 | 246 | function API:get_display_name(user_id) 247 | local data = self:_send("GET", "/profile/" .. self._http.quote(user_id) .. "/displayname") 248 | return data.displayname 249 | end 250 | 251 | function API:set_display_name(user_id, display_name) 252 | return self:_send("PUT", "/profile/" .. self._http.quote(user_id) .. "/displayname", 253 | nil, { displayname = display_name }) 254 | end 255 | 256 | function API:get_avatar_url(user_id) 257 | local data = self:_send("GET", "/profile/" .. self._http.quote(user_id) .. "/avatar_url") 258 | return data.avatar_url 259 | end 260 | 261 | function API:set_avatar_url(user_id, avatar_url) 262 | return self:_send("PUT", "/profile/" .. self._http.quote(user_id) .. "/avatar_url", 263 | nil, { avatar_url = avatar_url }) 264 | end 265 | 266 | function API:get_download_url(mxc_url) 267 | if mxc_url:sub(1, #"mxc://") == "mxc://" then 268 | -- TODO: De-hardcode API version 269 | return self.base_url .. "/_matrix/media/r0/download/" .. mxc_url:sub(7) 270 | end 271 | error("no mxc: scheme in URL: " .. mxc_url) 272 | end 273 | 274 | function API:_send_with_params(method, path, query_args, params, extra_params) 275 | for name, value in pairs(extra_params) do 276 | params[name] = value 277 | end 278 | return self:_send(method, path, query_args, params) 279 | end 280 | 281 | function API:_send(method, path, query_args, body, headers, api_path) 282 | -- Ensure that there is a Content-Type header. 283 | if not headers then 284 | headers = {} 285 | end 286 | if not headers["content-type"] then 287 | headers["content-type"] = "application/json" 288 | end 289 | 290 | -- Encode the request body, if necessary. 291 | if headers["content-type"] == "application/json" then 292 | body = body and json.encode(body) or "{}" 293 | elseif not body then 294 | body = "" 295 | end 296 | 297 | -- Copy the parameters, adding the access token. 298 | local params = { access_token = self.token } 299 | if query_args then 300 | for name, value in pairs(query_args) do 301 | params[name] = tostring(value) 302 | end 303 | end 304 | 305 | -- Call the HTTP library. 306 | self._log("-!- HTTP client: %s", self._http) 307 | local code, headers, body = self._http:request(self._log, method:upper(), 308 | self.base_url .. (api_path or self.api_path) .. path, params, body, headers) 309 | if code == 200 then 310 | if headers["content-type"] == "application/json" then 311 | body = json.decode(body) 312 | end 313 | return body 314 | else 315 | return error("HTTP " .. tostring(code) .. " - " .. body) 316 | end 317 | end 318 | 319 | 320 | return API 321 | -------------------------------------------------------------------------------- /matrix/client.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- client.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local json = require "cjson" 10 | local API = require "matrix.api" 11 | local eventable = require "matrix.eventable" 12 | 13 | local function noprintf(...) end 14 | local function eprintf(fmt, ...) 15 | io.stderr:write("[client] ") 16 | io.stderr:write(fmt:format(...)) 17 | io.stderr:write("\n") 18 | io.stderr:flush() 19 | end 20 | 21 | local function get_debug_log_function() 22 | local env_value = os.getenv("MATRIX_CLIENT_DEBUG_LOG") 23 | if env_value and #env_value > 0 and env_value ~= "0" then 24 | return eprintf 25 | else 26 | return noprintf 27 | end 28 | end 29 | 30 | 31 | local function sanitize(text) 32 | return (text:gsub("[^0-9a-zA-Z_]", "__")) 33 | end 34 | 35 | local function sorted_string_list_eq(a, b) 36 | if #a == #b then 37 | for i = 1, #a do 38 | if a[i] ~= b[i] then 39 | return false 40 | end 41 | end 42 | return true 43 | else 44 | return false 45 | end 46 | end 47 | 48 | local function set_simple_property(self, name, new_value) 49 | local old_value = self[name] 50 | if old_value == new_value then 51 | self:_log(".%s: %s (unchanged)", name, old_value) 52 | return false 53 | else 54 | self[name] = new_value 55 | self:_log(".%s: %s -> %s", name, old_value, new_value) 56 | self:fire("property-changed", name, old_value) 57 | return true 58 | end 59 | end 60 | 61 | local function set_string_list_property(self, name, new_value) 62 | local old_value = self[name] 63 | table.sort(old_value) 64 | table.sort(new_value) 65 | if sorted_string_list_eq(old_value, new_value) then 66 | self:_log(".%s: [%s] (unchanged)", name, table.concat(old_value, ", ")) 67 | return false 68 | else 69 | self[name] = new_value 70 | self:fire("property-changed", name, old_value, new_value) 71 | self:_log(".%s: [%s] -> [%s]", name, table.concat(old_value, ", "), 72 | table.concat(new_value, ", ")) 73 | return true 74 | end 75 | end 76 | 77 | 78 | local User = {} 79 | User.__name = "matrix.user" 80 | User.__index = User 81 | 82 | setmetatable(User, { __call = function (self, client, user_id) 83 | return eventable.object(setmetatable({ 84 | user_id = user_id, 85 | client = client, 86 | }, User)) 87 | end }) 88 | 89 | function User:__tostring() 90 | return self.__name .. "{" .. self.user_id .. "}" 91 | end 92 | 93 | function User:__eq(other) 94 | return getmetatable(other) == User and self.user_id == other.user_id 95 | end 96 | 97 | function User:_log(fmt, ...) 98 | self.client._log("{%s} " .. fmt, self.user_id, ...) 99 | end 100 | 101 | function User:update_display_name(value) 102 | if value and value ~= self.display_name then 103 | self.client._api:set_display_name(self.user_id, value) 104 | elseif not value then 105 | value = self.client._api:get_display_name(self.user_id) 106 | end 107 | return set_simple_property(self, "display_name", value) 108 | end 109 | 110 | function User:update_avatar_url(value) 111 | if value and value ~= self.avatar_url then 112 | self.client._api:set_avatar_url(self.user_id, value) 113 | elseif not value then 114 | value = self.client._api:get_avatar_url(self.user_id) 115 | end 116 | return set_simple_property(self, "avatar_url", value) 117 | end 118 | 119 | 120 | local Room = {} 121 | Room.__name = "matrix.room" 122 | Room.__index = Room 123 | 124 | setmetatable(Room, { __call = function (self, client, room_id) 125 | return eventable.object(setmetatable({ 126 | room_id = room_id, 127 | aliases = {}, 128 | members = {}, 129 | invited = {}, 130 | client = client, 131 | }, Room)) 132 | end }) 133 | 134 | function Room:__tostring() 135 | return self.__name .. "{" .. self.room_id .. "}" 136 | end 137 | 138 | function Room:__eq(other) 139 | return getmetatable(other) == Room and self.room_id == other.room_id 140 | end 141 | 142 | function Room:_log(fmt, ...) 143 | self.client._log("{%s} " .. fmt, self.room_id, ...) 144 | end 145 | 146 | function Room:send_text(text) 147 | -- XXX: How does error handling work here? 148 | return self.client._api:send_message(self.room_id, text).event_id 149 | end 150 | 151 | function Room:send_emote(text) 152 | -- XXX: How does error handling work here? 153 | return self.client._api:send_emote(self.room_id, text).event_id 154 | end 155 | 156 | function Room:send_notice(text) 157 | -- XXX: How does error handling work here? 158 | return self.client._api:send_notice(self.room_id, text).event_id 159 | end 160 | 161 | function Room:invite_user(user_id) 162 | -- XXX: Do we really want to pcall(), or should error propagate? 163 | return pcall(self.client._api.invite_user, 164 | self.client._api, self.room_id, user_id) 165 | end 166 | 167 | function Room:kick_user(user_id) 168 | -- XXX: Do we really want to pcall(), or should error propagate? 169 | return pcall(self.client._api.kick_user, 170 | self.client._api, self.room_id, user_id) 171 | end 172 | 173 | function Room:ban_user(user_id) 174 | -- XXX: Do we really want to pcall(), or should error propagate? 175 | return pcall(self.client._api.ban_user, 176 | self.client._api, self.room_id, user_id) 177 | end 178 | 179 | function Room:leave() 180 | -- XXX: Maybe this should use pcall()? 181 | self:fire("leave") 182 | self.client._api:leave_room(self.room_id) 183 | self.client.rooms[self.room_id] = nil 184 | self.client:fire("left", self) 185 | end 186 | 187 | function Room:update_room_name() 188 | local response = self.client._api:get_room_name(self.room_id) 189 | if response.name and response.name ~= self.name then 190 | return set_simple_property(self, "name", response.name) 191 | end 192 | return false 193 | end 194 | 195 | function Room:update_room_topic() 196 | local response = self.client._api:get_room_topic(self.room_id) 197 | if response and response.topic ~= self.topic then 198 | return set_simple_property(self, "topic", response.topic) 199 | end 200 | return false 201 | end 202 | 203 | function Room:update_aliases() 204 | local response = self.client._api:get_room_state(self.room_id) 205 | for _, chunk in ipairs(response) do 206 | if chunk.content and chunk.content.aliases then 207 | return set_string_list_property(self, "aliases", chunk.content.aliases) 208 | end 209 | end 210 | return false 211 | end 212 | 213 | function Room:get_alias_or_id() 214 | if self.canonical_alias then 215 | return self.canonical_alias 216 | elseif #self.aliases == 1 then 217 | return self.aliases[1] 218 | elseif #self.aliases > 1 then 219 | local shorter_index = 1 220 | for index, alias in ipairs(self.aliases) do 221 | if #alias < #self.aliases[shorter_index] then 222 | shorter_index = index 223 | end 224 | end 225 | return self.aliases[shorter_index] 226 | else 227 | return self.room_id 228 | end 229 | end 230 | 231 | local make_unimplemented_handler = function (self, event) 232 | local env_value = os.getenv("MATRIX_CLIENT_LOG_UNHANDLED_EVENTS") 233 | if env_value and #env_value > 0 and env_value ~= "0" then 234 | local function handler(self, event) 235 | self:_log("unhandled '%s' event: %s", event.type, json.encode(event)) 236 | end 237 | make_unimplemented_handler = function (self, event) 238 | return handler 239 | end 240 | else 241 | local function handler(self, event) end 242 | make_unimplemented_handler = function (self, event) 243 | self:_log("no handler for '%s' events (this warning is shown only once)", event.type) 244 | return handler 245 | end 246 | end 247 | return make_unimplemented_handler(self, event) 248 | end 249 | 250 | function Room:_push_events(events) 251 | self:_log("processing %d timeline events", #events) 252 | for _, event in ipairs(events) do 253 | local handler_name = "_push_event__" .. sanitize(event.type) 254 | local handler = self[handler_name] 255 | if not handler then 256 | handler = make_unimplemented_handler(self, event) 257 | self[handler_name] = handler 258 | end 259 | handler(self, event) 260 | end 261 | end 262 | 263 | function Room:_push_event__m__room__create(event) 264 | set_simple_property(self, "creator", event.content.creator) 265 | end 266 | 267 | function Room:_push_event__m__room__aliases(event) 268 | set_string_list_property(self, "aliases", event.content.aliases) 269 | end 270 | 271 | function Room:_push_event__m__room__canonical_alias(event) 272 | set_simple_property(self, "canonical_alias", event.content.alias) 273 | end 274 | 275 | function Room:_push_event__m__room__name(event) 276 | set_simple_property(self, "name", event.content.name) 277 | end 278 | 279 | function Room:_push_event__m__room__join_rules(event) 280 | set_simple_property(self, "join_rule", event.content.join_rule) 281 | end 282 | 283 | function Room:_push_event__m__room__history_visibility(event) 284 | set_simple_property(self, "history_visibility", event.content.history_visibility) 285 | end 286 | 287 | function Room:_push_event__m__room__member(event) 288 | if event.content.membership == "join" then 289 | local user = self.client:_make_user(event.state_key, 290 | event.content.displayname, event.content.avatar_url) 291 | self.members[user.user_id] = user 292 | self:fire("member-joined", user) 293 | elseif event.content.membership == "invite" then 294 | local user = self.client:_make_user(event.state_key, 295 | event.content.displayname, event.content.avatar_url) 296 | -- FIXME: Setting property from outside the User object itself. 297 | set_simple_property(user, "invited_by", event.sender) 298 | self.invited[user.user_id] = user 299 | self:fire("member-invited", user) 300 | elseif event.content.membership == "leave" then 301 | local user = self.members[event.state_key] or self.invited[event.state_key] 302 | if user then 303 | if user.invited_by then 304 | self.invited[user.user_id] = nil 305 | else 306 | self.members[user.user_id] = nil 307 | end 308 | self:fire("member-left", user) 309 | -- TODO: Do we remove the user from self.client.presence?? 310 | end 311 | else 312 | error("Unhandled event: " .. json.encode(event)) 313 | end 314 | end 315 | 316 | function Room:_push_event__m__room__message(event) 317 | self:fire("message", event.sender, event.content, event) 318 | end 319 | 320 | 321 | local Client = {} 322 | Client.__name = "matrix.client" 323 | Client.__index = Client 324 | 325 | setmetatable(Client, { __call = function (self, base_url, token, http_client) 326 | return eventable.object(setmetatable({ 327 | presence = {}, -- Indexed by user_id 328 | rooms = {}, -- Indexed by room_id 329 | _log = get_debug_log_function(), 330 | _api = API(base_url, token, http_client), 331 | }, Client)) 332 | end }) 333 | 334 | function Client:__tostring() 335 | return self.__name .. "{" .. self._api.base_url .. "}" 336 | end 337 | 338 | function Client:register_with_password(username, password) 339 | return self:_logged_in(self._api:register("m.login.password", 340 | { user = username, password = password })) 341 | end 342 | 343 | function Client:login_with_password(username, password) 344 | return self:_logged_in(self._api:login("m.login.password", 345 | { user = username, password = password })) 346 | end 347 | 348 | function Client:_logged_in(response) 349 | self._log("logged-in: %s", response.user_id) 350 | self.user_id = response.user_id 351 | self.homeserver = response.home_server 352 | self.token = response.access_token 353 | self._api.token = response.access_token 354 | self:fire("logged-in") 355 | return self.token 356 | end 357 | 358 | function Client:logout() 359 | local ret = self._api:logout() 360 | self:fire("logged-out") 361 | return ret 362 | end 363 | 364 | function Client:get_user() 365 | return self.user_id and User(self, self.user_id) or nil 366 | end 367 | 368 | function Client:create_room(alias, public, invite) 369 | local response = self._api:create_room { 370 | alias = alias, 371 | public = public, 372 | invite = invite, 373 | } 374 | return self:_make_room(response.room_id) 375 | end 376 | 377 | function Client:join_room(room) 378 | if type(room) == "string" then 379 | local response = self._api:join_room(room) 380 | elseif type(room) == "table" and getmetatable(room) == Room then 381 | local response = self._api:join_room(room.room_id) 382 | else 383 | error("argument #1 must be a string or a room object") 384 | end 385 | return self:_make_room(response.room_id) 386 | end 387 | 388 | function Client:_make_room(room_id) 389 | local room = self.rooms[room_id] 390 | if not room then 391 | room = Room(self, room_id) 392 | self.rooms[room_id] = room 393 | self:fire("joined", room) 394 | end 395 | return room 396 | end 397 | 398 | function Client:find_room(room_id_or_alias) 399 | for room_id, room in pairs(self.rooms) do 400 | if room_id_or_alias == room_id or 401 | room_id_or_alias == room.canonical_alias 402 | then 403 | return room 404 | end 405 | for _, alias in ipairs(room.aliases) do 406 | if room_id_or_alias == alias then 407 | return room 408 | end 409 | end 410 | end 411 | end 412 | 413 | function Client:_make_user(user_id, display_name, avatar_url) 414 | local user = self.presence[user_id] 415 | if not user then 416 | user = User(self, user_id) 417 | self.presence[user_id] = user 418 | end 419 | -- Set properties directly to avoid issues set_* API calls. 420 | set_simple_property(user, "display_name", display_name) 421 | set_simple_property(user, "avatar_url", avatar_url) 422 | return user 423 | end 424 | 425 | local function xpcall_add_traceback(errmsg) 426 | local tb = debug.traceback(nil, nil, 2) 427 | if errmsg then 428 | return errmsg .. "\n" .. tb 429 | else 430 | return tb 431 | end 432 | end 433 | 434 | function Client:_sync(options) 435 | if not options then 436 | options = {} 437 | end 438 | if not options.since then 439 | options.since = self._sync_next_batch 440 | end 441 | self._log("sync: Requesting with next_batch = %s", options.since) 442 | 443 | local response = self._api:sync(options) 444 | self._sync_next_batch = response.next_batch 445 | 446 | for _, kind in ipairs { "join", "invite", "leave" } do 447 | local handle = self["_sync_handle_room__" .. kind] 448 | for room_id, room_data in pairs(response.rooms[kind]) do 449 | self._log("sync: %s %s", kind, room_id) 450 | -- XXX: Maybe this is abusing pcall() too much to allow handler 451 | -- code to bail and continue with the next room instead of 452 | -- completely failing to sync. Dunno. 453 | local ok, err = xpcall(handle, xpcall_add_traceback, self, room_id, room_data) 454 | if not ok then 455 | self._log("sync: Error handling '%s' event for room %s:\n%s", kind, room_id, err) 456 | self._log("sync: Event payload: %s", json.encode(room_data)) 457 | end 458 | end 459 | end 460 | end 461 | 462 | local function return_false() 463 | return false 464 | end 465 | 466 | function Client:sync(stop, timeout) 467 | if not stop then 468 | stop = return_false 469 | end 470 | repeat 471 | self:_sync { timeout = timeout or 15000 } 472 | until stop(self) 473 | end 474 | 475 | function Client:_sync_handle_room__join(room_id, data) 476 | local room = self:_make_room(room_id) 477 | room:_push_events(data.timeline.events) 478 | end 479 | 480 | function Client:_sync_handle_room__invite(room_id, data) 481 | local room = Room(self, room_id) 482 | room:_push_events(data.invite_state.events) 483 | self:fire("invite", room) 484 | end 485 | 486 | function Client:_sync_handle_room__leave(room_id, data) 487 | local room = assert(self.rooms[room_id], "No such room") 488 | room:_push_timeline_events(data.timeline) 489 | room:leave() 490 | end 491 | 492 | 493 | return { room = Room, user = User, client = Client, api = API } 494 | --------------------------------------------------------------------------------