Files
lua-matrix/matrix/client.lua
Adrian Perez de Castro cadda45130 Handle "join" events properly
This handles the fact that when using the /sync endpoint, events for already
joined rooms are also in the "join" dictionary in the response. The firing of
the "joined" event is moved to client:_make_room() and done only the first
time that the room creation is requested.

This also fixes bogus behavior: previosly, when a message was sent to a room
there would be multiople places where the "joined" event would be fired, and
message events pushed multile times onto the room object, which would call
the event handlers more than once for each message. Ugh.
2016-07-05 01:03:23 +03:00

460 lines
13 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)
return self.client._api:send_message(self.room_id, text)
end
function Room:send_emote(text)
return self.client._api:send_emote(self.room_id, text)
end
function Room:send_notice(text)
return self.client._api:send_notice(self.room_id, text)
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
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)
local c = 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))
-- Do an initial sync if a token was provided on construction.
if token then
c:_sync()
end
return c
end })
function Client:__tostring()
return self.__name .. "{" .. self._api.base_url .. "}"
end
function Client:register_with_password(username, password, no_sync)
return self:_logged_in(self._api:register("m.login.password",
{ user = username, password = password }), no_sync)
end
function Client:login_with_password(username, password, no_sync)
return self:_logged_in(self._api:login("m.login.password",
{ user = username, password = password }), no_sync)
end
function Client:_logged_in(response, no_sync)
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")
if not no_sync then
self:_sync()
end
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:_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
function Client:sync(niters)
while niters == nil or niters > 0 do
self:_sync { timeout = 15000 }
if niters then
niters = niters - 1
end
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 }