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.
460 lines
13 KiB
Lua
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 }
|