Files
lua-matrix/matrix/client.lua
Adrian Perez de Castro ae18e3374d Support implicitly syncing on login
Whether to sync after connecting or not is desired depends on the application,
and it's better to remove the possibility of doing so. Having to done one
explicit call to :sync() is not a big deal, and makes the intention of code
using the module clearer. And our code simpler.
2017-02-05 11:14:25 +01:00

492 lines
14 KiB
Lua

#! /usr/bin/env lua
--
-- client.lua
-- Copyright (C) 2016 Adrian Perez <aperez@igalia.com>
--
-- Distributed under terms of the MIT license.
--
local json = require "cjson"
local API = require "matrix.api"
local eventable = require "matrix.eventable"
local function noprintf(...) end
local function eprintf(fmt, ...)
io.stderr:write("[client] ")
io.stderr:write(fmt:format(...))
io.stderr:write("\n")
io.stderr:flush()
end
local function get_debug_log_function()
local env_value = os.getenv("MATRIX_CLIENT_DEBUG_LOG")
if env_value and #env_value > 0 and env_value ~= "0" then
return eprintf
else
return noprintf
end
end
local function sanitize(text)
return (text:gsub("[^0-9a-zA-Z_]", "__"))
end
local function sorted_string_list_eq(a, b)
if #a == #b then
for i = 1, #a do
if a[i] ~= b[i] then
return false
end
end
return true
else
return false
end
end
local function set_simple_property(self, name, new_value)
local old_value = self[name]
if old_value == new_value then
self:_log(".%s: %s (unchanged)", name, old_value)
return false
else
self[name] = new_value
self:_log(".%s: %s -> %s", name, old_value, new_value)
self:fire("property-changed", name, old_value)
return true
end
end
local function set_string_list_property(self, name, new_value)
local old_value = self[name]
table.sort(old_value)
table.sort(new_value)
if sorted_string_list_eq(old_value, new_value) then
self:_log(".%s: [%s] (unchanged)", name, table.concat(old_value, ", "))
return false
else
self[name] = new_value
self:fire("property-changed", name, old_value, new_value)
self:_log(".%s: [%s] -> [%s]", name, table.concat(old_value, ", "),
table.concat(new_value, ", "))
return true
end
end
local User = {}
User.__name = "matrix.user"
User.__index = User
setmetatable(User, { __call = function (self, client, user_id)
return eventable.object(setmetatable({
user_id = user_id,
client = client,
}, User))
end })
function User:__tostring()
return self.__name .. "{" .. self.user_id .. "}"
end
function User:__eq(other)
return getmetatable(other) == User and self.user_id == other.user_id
end
function User:_log(fmt, ...)
self.client._log("{%s} " .. fmt, self.user_id, ...)
end
function User:update_display_name(value)
if value and value ~= self.display_name then
self.client._api:set_display_name(self.user_id, value)
elseif not value then
value = self.client._api:get_display_name(self.user_id)
end
return set_simple_property(self, "display_name", value)
end
function User:update_avatar_url(value)
if value and value ~= self.avatar_url then
self.client._api:set_avatar_url(self.user_id, value)
elseif not value then
value = self.client._api:get_avatar_url(self.user_id)
end
return set_simple_property(self, "avatar_url", value)
end
local Room = {}
Room.__name = "matrix.room"
Room.__index = Room
setmetatable(Room, { __call = function (self, client, room_id)
return eventable.object(setmetatable({
room_id = room_id,
aliases = {},
members = {},
invited = {},
client = client,
}, Room))
end })
function Room:__tostring()
return self.__name .. "{" .. self.room_id .. "}"
end
function Room:__eq(other)
return getmetatable(other) == Room and self.room_id == other.room_id
end
function Room:_log(fmt, ...)
self.client._log("{%s} " .. fmt, self.room_id, ...)
end
function Room:send_text(text)
-- XXX: How does error handling work here?
return self.client._api:send_message(self.room_id, text).event_id
end
function Room:send_emote(text)
-- XXX: How does error handling work here?
return self.client._api:send_emote(self.room_id, text).event_id
end
function Room:send_notice(text)
-- XXX: How does error handling work here?
return self.client._api:send_notice(self.room_id, text).event_id
end
function Room:invite_user(user_id)
-- XXX: Do we really want to pcall(), or should error propagate?
return pcall(self.client._api.invite_user,
self.client._api, self.room_id, user_id)
end
function Room:kick_user(user_id)
-- XXX: Do we really want to pcall(), or should error propagate?
return pcall(self.client._api.kick_user,
self.client._api, self.room_id, user_id)
end
function Room:ban_user(user_id)
-- XXX: Do we really want to pcall(), or should error propagate?
return pcall(self.client._api.ban_user,
self.client._api, self.room_id, user_id)
end
function Room:leave()
-- XXX: Maybe this should use pcall()?
self:fire("leave")
self.client._api:leave_room(self.room_id)
self.client.rooms[self.room_id] = nil
self.client:fire("left", self)
end
function Room:update_room_name()
local response = self.client._api:get_room_name(self.room_id)
if response.name and response.name ~= self.name then
return set_simple_property(self, "name", response.name)
end
return false
end
function Room:update_room_topic()
local response = self.client._api:get_room_topic(self.room_id)
if response and response.topic ~= self.topic then
return set_simple_property(self, "topic", response.topic)
end
return false
end
function Room:update_aliases()
local response = self.client._api:get_room_state(self.room_id)
for _, chunk in ipairs(response) do
if chunk.content and chunk.content.aliases then
return set_string_list_property(self, "aliases", chunk.content.aliases)
end
end
return false
end
function Room:get_alias_or_id()
if self.canonical_alias then
return self.canonical_alias
elseif #self.aliases == 1 then
return self.aliases[1]
elseif #self.aliases > 1 then
local shorter_index = 1
for index, alias in ipairs(self.aliases) do
if #alias < #self.aliases[shorter_index] then
shorter_index = index
end
end
return self.aliases[shorter_index]
else
return self.room_id
end
end
local make_unimplemented_handler = function (self, event)
local env_value = os.getenv("MATRIX_CLIENT_LOG_UNHANDLED_EVENTS")
if env_value and #env_value > 0 and env_value ~= "0" then
local function handler(self, event)
self:_log("unhandled '%s' event: %s", event.type, json.encode(event))
end
make_unimplemented_handler = function (self, event)
return handler
end
else
local function handler(self, event) end
make_unimplemented_handler = function (self, event)
self:_log("no handler for '%s' events (this warning is shown only once)", event.type)
return handler
end
end
return make_unimplemented_handler(self, event)
end
function Room:_push_events(events)
self:_log("processing %d timeline events", #events)
for _, event in ipairs(events) do
local handler_name = "_push_event__" .. sanitize(event.type)
local handler = self[handler_name]
if not handler then
handler = make_unimplemented_handler(self, event)
self[handler_name] = handler
end
handler(self, event)
end
end
function Room:_push_event__m__room__create(event)
set_simple_property(self, "creator", event.content.creator)
end
function Room:_push_event__m__room__aliases(event)
set_string_list_property(self, "aliases", event.content.aliases)
end
function Room:_push_event__m__room__canonical_alias(event)
set_simple_property(self, "canonical_alias", event.content.alias)
end
function Room:_push_event__m__room__name(event)
set_simple_property(self, "name", event.content.name)
end
function Room:_push_event__m__room__join_rules(event)
set_simple_property(self, "join_rule", event.content.join_rule)
end
function Room:_push_event__m__room__history_visibility(event)
set_simple_property(self, "history_visibility", event.content.history_visibility)
end
function Room:_push_event__m__room__member(event)
if event.content.membership == "join" then
local user = self.client:_make_user(event.state_key,
event.content.displayname, event.content.avatar_url)
self.members[user.user_id] = user
self:fire("member-joined", user)
elseif event.content.membership == "invite" then
local user = self.client:_make_user(event.state_key,
event.content.displayname, event.content.avatar_url)
-- FIXME: Setting property from outside the User object itself.
set_simple_property(user, "invited_by", event.sender)
self.invited[user.user_id] = user
self:fire("member-invited", user)
elseif event.content.membership == "leave" then
local user = self.members[event.state_key] or self.invited[event.state_key]
if user then
if user.invited_by then
self.invited[user.user_id] = nil
else
self.members[user.user_id] = nil
end
self:fire("member-left", user)
-- TODO: Do we remove the user from self.client.presence??
end
else
error("Unhandled event: " .. json.encode(event))
end
end
function Room:_push_event__m__room__message(event)
self:fire("message", event.sender, event.content, event)
end
local Client = {}
Client.__name = "matrix.client"
Client.__index = Client
setmetatable(Client, { __call = function (self, base_url, token, http_client)
return eventable.object(setmetatable({
presence = {}, -- Indexed by user_id
rooms = {}, -- Indexed by room_id
_log = get_debug_log_function(),
_api = API(base_url, token, http_client),
}, Client))
end })
function Client:__tostring()
return self.__name .. "{" .. self._api.base_url .. "}"
end
function Client:register_with_password(username, password)
return self:_logged_in(self._api:register("m.login.password",
{ user = username, password = password }))
end
function Client:login_with_password(username, password)
return self:_logged_in(self._api:login("m.login.password",
{ user = username, password = password }))
end
function Client:_logged_in(response)
self._log("logged-in: %s", response.user_id)
self.user_id = response.user_id
self.homeserver = response.home_server
self.token = response.access_token
self._api.token = response.access_token
self:fire("logged-in")
return self.token
end
function Client:logout()
local ret = self._api:logout()
self:fire("logged-out")
return ret
end
function Client:get_user()
return self.user_id and User(self, self.user_id) or nil
end
function Client:create_room(alias, public, invite)
local response = self._api:create_room {
alias = alias,
public = public,
invite = invite,
}
return self:_make_room(response.room_id)
end
function Client:join_room(room)
if type(room) == "string" then
local response = self._api:join_room(room)
elseif type(room) == "table" and getmetatable(room) == Room then
local response = self._api:join_room(room.room_id)
else
error("argument #1 must be a string or a room object")
end
return self:_make_room(response.room_id)
end
function Client:_make_room(room_id)
local room = self.rooms[room_id]
if not room then
room = Room(self, room_id)
self.rooms[room_id] = room
self:fire("joined", room)
end
return room
end
function Client:find_room(room_id_or_alias)
for room_id, room in pairs(self.rooms) do
if room_id_or_alias == room_id or
room_id_or_alias == room.canonical_alias
then
return room
end
for _, alias in ipairs(room.aliases) do
if room_id_or_alias == alias then
return room
end
end
end
end
function Client:_make_user(user_id, display_name, avatar_url)
local user = self.presence[user_id]
if not user then
user = User(self, user_id)
self.presence[user_id] = user
end
-- Set properties directly to avoid issues set_* API calls.
set_simple_property(user, "display_name", display_name)
set_simple_property(user, "avatar_url", avatar_url)
return user
end
local function xpcall_add_traceback(errmsg)
local tb = debug.traceback(nil, nil, 2)
if errmsg then
return errmsg .. "\n" .. tb
else
return tb
end
end
function Client:_sync(options)
if not options then
options = {}
end
options.since = self._sync_next_batch
self._log("sync: Requesting with next_batch = %s", options.since)
local response = self._api:sync(options)
self._sync_next_batch = response.next_batch
for _, kind in ipairs { "join", "invite", "leave" } do
local handle = self["_sync_handle_room__" .. kind]
for room_id, room_data in pairs(response.rooms[kind]) do
self._log("sync: %s %s", kind, room_id)
-- XXX: Maybe this is abusing pcall() too much to allow handler
-- code to bail and continue with the next room instead of
-- completely failing to sync. Dunno.
local ok, err = xpcall(handle, xpcall_add_traceback, self, room_id, room_data)
if not ok then
self._log("sync: Error handling '%s' event for room %s:\n%s", kind, room_id, err)
self._log("sync: Event payload: %s", json.encode(room_data))
end
end
end
end
local function return_false()
return false
end
function Client:sync(stop, timeout)
if not stop then
stop = return_false
end
while not stop(self) do
self:_sync { timeout = timeout or 15000 }
end
end
function Client:_sync_handle_room__join(room_id, data)
local room = self:_make_room(room_id)
room:_push_events(data.timeline.events)
end
function Client:_sync_handle_room__invite(room_id, data)
local room = Room(self, room_id)
room:_push_events(data.invite_state.events)
self:fire("invite", room)
end
function Client:_sync_handle_room__leave(room_id, data)
local room = assert(self.rooms[room_id], "No such room")
room:_push_timeline_events(data.timeline)
room:leave()
end
return { room = Room, user = User, client = Client, api = API }