├── README.md ├── matrix.lua ├── olm.lua └── update.sh /README.md: -------------------------------------------------------------------------------- 1 | # Matrix Client script for WeeChat 2 | 3 | Also known as WeeMatrix, this script is considered beta quality as not all functionality is in place and still has known bugs, and unknown bugs to be discovered and fixed. 4 | 5 | ## Project Status and Alternative Client 6 | 7 | While this script generally works for general usage, there's a new script that's more actively developed here: https://github.com/poljar/weechat-matrix 8 | 9 | ## What is Matrix ? 10 | 11 | Matrix is a new open source realtime federated chat protocol. You can read more about it on [their website](http://matrix.org/blog/faq/). 12 | 13 | ## What is WeeChat ? 14 | 15 | [WeeChat](http://weechat.org) is a super powerful CLI chat client that's extensible in many languages to allow for new protocols like Matrix. 16 | 17 | ## What does it look like? 18 | 19 | > WeeChat in a terminal 20 | 21 | ![weechat ncurses cli screenshot](https://hveem.no/ss/weechat-matrix-ss.png) 22 | 23 | > WeeChat in a relay client [Glowing Bear](http://github.com/glowing-bear) 24 | 25 | ![weechat glowing bear](https://hveem.no/ss/weechat-matrix-gb.png) 26 | 27 | ## How to load and use the plugin 28 | 29 | ```bash 30 | # Clone this repo 31 | git clone https://github.com/torhve/weechat-matrix-protocol-script.git 32 | # Copy the script into WeeChat's Lua dir 33 | cp weechat-matrix-protocol-script/matrix.lua ~/.weechat/lua/ 34 | # Make a symlink into the autoload dir to load the script automatically when WeeChat starts 35 | ln -s ~/.weechat/lua/matrix.lua ~/.weechat/lua/autoload/ 36 | # Start WeeChat 37 | weechat 38 | ``` 39 | Helpful commands after starting WeeChat 40 | ``` 41 | # If you didn't put matrix.lua in autoload 42 | /script load matrix.lua 43 | # Set the two required settings. Look in WeeChat docs for secdata if you don't want to store passord in the clear. ( http://dev.weechat.org/post/2013/08/04/Secured-data ) 44 | /set plugins.var.lua.matrix.user username 45 | /set plugins.var.lua.matrix.password secret 46 | /matrix connect 47 | 48 | # to display all the possible WeeChat Matrix settings: 49 | /set plugins.var.lua.matrix.* 50 | # to get a description for each option 51 | /help plugins.var.lua.matrix.local_echo 52 | 53 | ``` 54 | 55 | When it connects it will create a WeeChat buffer called matrix that will act 56 | like the "server" buffer. Some errors messages and status messages will appear 57 | in there on certain events like joing/invite/connection problems/etc. It will 58 | also create buffers for any rooms that you have already join on this or any 59 | other client. 60 | 61 | Most IRC commands you would expect work exists, like /join /query /part /kick 62 | /op /voice /notice, etc. There might be slight differences from normal usage. 63 | 64 | There's a /create room name to create a new room with a given name. 65 | 66 | If at any point you get any errors you can always try reloading the script to 67 | refresh the state by issuing the command 68 | 69 | ``` 70 | /script reload matrix 71 | ``` 72 | 73 | If you get invited to a room, the script will autojoin, which is configurable 74 | behaviour. 75 | 76 | ## Configuration 77 | 78 | Given Matrix display names can be quite long, we recommend limiting 79 | the size of the prefix and username lists: 80 | 81 | ``` 82 | /set weechat.look.prefix_align_max 20 83 | /set weechat.bar.nicklist.size 20 84 | ``` 85 | 86 | To get an item on your status bar to show users currently typing in the active room buffer add the `matrix_typing_notice` item to the status bar like this: 87 | ``` 88 | /set weechat.bar.status.items [buffer_count],[buffer_plugin],buffer_number+:+buffer_name+{buffer_nicklist_count}+buffer_filter,[hotlist],completion,scroll,matrix_typing_notice 89 | ``` 90 | 91 | It's helpful to use tab-complete after typing the option name to get the current setting, and then just add it to the end 92 | 93 | ## To hot-update the plugin 94 | 95 | ```bash 96 | git pull 97 | ``` 98 | 99 | ``` 100 | /script reload matrix 101 | ``` 102 | 103 | ## How to get WeeChat & Lua deps up and running on Debian/Ubuntu: 104 | 105 | ```bash 106 | sudo apt-get install weechat lua-cjson 107 | ``` 108 | 109 | Note that the weechat in jessie (1.0.1) is quite old compared to upstream and is 110 | missing many bugfixes. This plugin may not work with older versions. There are 111 | unofficial packages [here](https://weechat.org/download/debian/). 112 | 113 | ## How to get WeeChat & Lua deps up and running on CentOS/RHEL: 114 | 115 | ```bash 116 | sudo yum install epel-release 117 | sudo yum install weechat lua lua-devel luarocks 118 | sudo luarocks install lua-cjson 119 | ``` 120 | 121 | ## How to get WeeChat & Lua deps up and running on OSX: 122 | 123 | ### using brew (recommended; it's a simpler faster install and ships newer weechat than MacPorts): 124 | ```bash 125 | brew install lua 126 | luarocks install lua-cjson 127 | brew install weechat --with-lua 128 | ``` 129 | 130 | ### using MacPorts: 131 | ```bash 132 | sudo port install weechat +lua 133 | sudo port install luarocks 134 | sudo luarocks install lua-cjson 135 | # You may need to substitute the version number for whatever version of lua macports installed 136 | export LUA_PATH="/opt/local/share/luarocks/share/lua/5.3/?.lua;/opt/local/share/luarocks/share/lua/5.3/?/init.lua;$LUA_PATH" 137 | export LUA_CPATH="/opt/local/share/luarocks/lib/lua/5.3/?.so;$LUA_CPATH" 138 | ``` 139 | 140 | ## How to get WeeChat & Lua deps up and running on Arch: 141 | ```bash 142 | sudo pacman -S lua weechat 143 | # You can grab lua-cjson from either the AUR: 144 | pacaur -y lua-cjson 145 | # Or through the luarocks package manager: 146 | luarocks install lua-cjson 147 | ``` 148 | 149 | # Encryption 150 | 151 | The current encryption implementation in weechat-matrix-protocol is incompatible with Matrix. It was written for an early proof-of-concept version of the protocol that used Olm, and does not work with the current Matrix protocol which utilises Megolm. 152 | 153 | Help appreciated to get it working! 154 | 155 | # License 156 | 157 | MIT 158 | -------------------------------------------------------------------------------- /matrix.lua: -------------------------------------------------------------------------------- 1 | -- WeeChat Matrix.org Client 2 | -- vim: expandtab:ts=4:sw=4:sts=4 3 | -- luacheck: globals weechat command_help command_connect matrix_command_cb matrix_away_command_run_cb configuration_changed_cb real_http_cb matrix_unload http_cb upload_cb send buffer_input_cb poll polltimer_cb cleartyping otktimer_cb join_command_cb part_command_cb leave_command_cb me_command_cb topic_command_cb upload_command_cb query_command_cb create_command_cb createalias_command_cb invite_command_cb list_command_cb op_command_cb voice_command_cb devoice_command_cbtow.config_get_plugin('timeout')) kick_command_cb deop_command_cb nick_command_cb whois_command_cb notice_command_cb msg_command_cb encrypt_command_cb public_command_cb names_command_cb more_command_cb roominfo_command_cb name_command_cb closed_matrix_buffer_cb closed_matrix_room_cb typing_notification_cb buffer_switch_cb typing_bar_item_cb devoice_command_cb 4 | -- lots of shadowing here, just ignore it 5 | -- luacheck: ignore current_buffer 6 | 7 | --[[ 8 | Author: xt 9 | Thanks to Ryan Huber of wee_slack.py for some ideas and inspiration. 10 | 11 | This script is considered alpha quality as only the bare minimal of 12 | functionality is in place and it is not very well tested. 13 | 14 | It is known to be able to crash WeeChat in certain scenarioes so all 15 | usage of this script is at your own risk. 16 | 17 | If at any point there seems to be problem, make sure you update to 18 | the latest version of this script. You can also try reloading the 19 | script using /lua reload matrix to refresh all the state. 20 | 21 | Power Levels 22 | ------------ 23 | 24 | A default Matrix room has power level between 0 to 100. 25 | This script maps this as follows: 26 | 27 | ~ Room creator 28 | & Power level 100 29 | @ Power level 50 30 | + Power level > 0 31 | 32 | TODO 33 | ---- 34 | /ban 35 | Giving people arbitrary power levels 36 | Lazyload messages instead of HUGE initialSync 37 | Dynamically fetch more messages in backlog when user reaches the 38 | oldest message using pgup 39 | Need a way to change room join rule 40 | Fix broken state after failed initial connect 41 | Fix parsing of multiple join messages 42 | Friendlier error message on bad user/password 43 | Parse some HTML and turn into color/bold/etc 44 | Support weechat.look.prefix_same_nick 45 | 46 | ]] 47 | 48 | local json = require 'cjson' -- apt-get install lua-cjson 49 | local olmstatus, olm = pcall(require, 'olm') -- LuaJIT olm FFI binding ln -s ~/olm/olm.lua /usr/local/share/lua/5.1 50 | local w = weechat 51 | 52 | local SCRIPT_NAME = "matrix" 53 | local SCRIPT_AUTHOR = "xt " 54 | local SCRIPT_VERSION = "3" 55 | local SCRIPT_LICENSE = "MIT" 56 | local SCRIPT_DESC = "Matrix.org chat plugin" 57 | local SCRIPT_COMMAND = SCRIPT_NAME 58 | 59 | local WEECHAT_VERSION 60 | 61 | local SERVER 62 | local STDOUT = {} 63 | local OUT = {} 64 | local BUFFER 65 | local Room 66 | local MatrixServer 67 | local Olm 68 | local DEBUG = false 69 | -- How many seconds to timeout if nothing happened on the server. If something 70 | -- happens before it will return sooner. 71 | -- default Nginx proxy timeout is 60s, so we go slightly lower 72 | local POLL_INTERVAL = 55 73 | 74 | -- Time in seconds until a connection is assumed to be timed out. 75 | -- Floating values like 0.4 should work too. 76 | local timeout = 5*1000 -- overriden by w.config_get_plugin later 77 | 78 | local current_buffer 79 | 80 | local default_color = w.color('default') 81 | -- Cache error variables so we don't have to look them up for every error 82 | -- message, a normal user will not change these ever anyway. 83 | local errprefix 84 | local errprefix_c 85 | 86 | local HOMEDIR 87 | local OLM_ALGORITHM = 'm.olm.v1.curve25519-aes-sha2' 88 | local OLM_KEY = 'secr3t' -- TODO configurable using weechat sec data 89 | local v2_api_ns = '_matrix/client/v2_alpha' 90 | 91 | local function tprint(tbl, indent, out) 92 | if not indent then indent = 0 end 93 | if not out then out = BUFFER end 94 | for k, v in pairs(tbl) do 95 | local formatting = string.rep(" ", indent) .. k .. ": " 96 | if type(v) == "table" then 97 | w.print(out, formatting) 98 | tprint(v, indent+1, out) 99 | elseif type(v) == 'boolean' then 100 | w.print(out, formatting .. tostring(v)) 101 | elseif type(v) == 'userdata' then 102 | w.print(out, formatting .. tostring(v)) 103 | else 104 | w.print(out, formatting .. v) 105 | end 106 | end 107 | end 108 | 109 | local function mprint(message) 110 | -- Print message to matrix buffer 111 | if type(message) == 'table' then 112 | tprint(message) 113 | else 114 | message = tostring(message) 115 | w.print(BUFFER, message) 116 | end 117 | end 118 | 119 | local function perr(message) 120 | if message == nil then return end 121 | -- Print error message to the matrix "server" buffer using WeeChat styled 122 | -- error message 123 | mprint( 124 | errprefix_c .. 125 | errprefix .. 126 | '\t' .. 127 | default_color .. 128 | tostring(message) 129 | ) 130 | end 131 | 132 | local function dbg(message) 133 | perr('- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -') 134 | if type(message) == 'table' then 135 | tprint(message) 136 | else 137 | message = ("DEBUG\t%s"):format(tostring(message)) 138 | mprint(BUFFER, message) 139 | end 140 | end 141 | 142 | local dtraceback = debug.traceback 143 | -- luacheck: ignore debug 144 | debug.traceback = function (...) 145 | if select('#', ...) >= 1 then 146 | local err, lvl = ... 147 | local trace = dtraceback(err, (lvl or 2)+1) 148 | perr(trace) 149 | end 150 | -- direct call to debug.traceback: return the original. 151 | -- debug.traceback(nil, level) doesn't work in Lua 5.1 152 | -- (http://lua-users.org/lists/lua-l/2011-06/msg00574.html), so 153 | -- simply remove first frame from the stack trace 154 | return (dtraceback(...):gsub("(stack traceback:\n)[^\n]*\n", "%1")) 155 | end 156 | 157 | local function weechat_eval(text) 158 | if WEECHAT_VERSION >= 0x00040200 then 159 | return w.string_eval_expression(text,{},{},{}) 160 | end 161 | return text 162 | end 163 | 164 | local urllib = {} 165 | urllib.quote = function(str) 166 | if not str then return '' end 167 | if type(str) == 'number' then return str end 168 | return str:gsub( 169 | '([^%w ])', 170 | function (c) 171 | return string.format ("%%%02X", string.byte(c)) 172 | end 173 | ):gsub(' ', '+') 174 | end 175 | urllib.urlencode = function(tbl) 176 | local out = {} 177 | for k, v in pairs(tbl) do 178 | table.insert(out, urllib.quote(k)..'='..urllib.quote(v)) 179 | end 180 | return table.concat(out, '&') 181 | end 182 | 183 | local function accesstoken_redact(str) 184 | return (str:gsub('access.*token=[0-9a-zA-Z%%]*', 'access_token=[redacted]')) 185 | end 186 | 187 | local transaction_id_counter = 0 188 | local function get_next_transaction_id() 189 | transaction_id_counter = transaction_id_counter + 1 190 | return transaction_id_counter 191 | end 192 | 193 | --[[ 194 | -- Function for signing json, unused for now, we hand craft the required 195 | -- signed json in the encryption function. But I think this function will be 196 | -- needed in the future so leaving it here in a commented version 197 | local function sign_json(json_object, signing_key, signing_name) 198 | -- See: https://github.com/matrix-org/matrix-doc/blob/master/specification/31_event_signing.rst 199 | -- Maybe use:http://regex.info/code/JSON.lua which sorts keys 200 | local signatures = json_object.signatures or {} 201 | json_object.signatures = nil 202 | 203 | local unsigned = json_object.unsigned or nil 204 | json_object.unsigned = nil 205 | 206 | -- TODO ensure canonical json 207 | local signed = signing_key:sign(json.encode(json_object)) 208 | local signature_base64 = encode_base64(signed.signature) 209 | 210 | local key_id = ("%s:%s"):format(signing_key.agl, signing_key.version) 211 | signatures[signing_name] = {[key_id] = signature_base64} 212 | 213 | json_object.signatures = signatures 214 | if unsigned then 215 | json_object.unsigned = unsigned 216 | end 217 | 218 | return json_object 219 | end 220 | --]] 221 | 222 | local function split_args(args) 223 | local function_name, arg = args:match('^(.-) (.*)$') 224 | return function_name, arg 225 | end 226 | 227 | local function byte_to_tag(s, byte, open_tag, close_tag) 228 | if s:match(byte) then 229 | local inside = false 230 | local open_tags = 0 231 | local htmlbody = s:gsub(byte, function(c) 232 | if inside then 233 | inside = false 234 | return close_tag 235 | end 236 | inside = true 237 | open_tags = open_tags + 1 238 | return open_tag 239 | end) 240 | local _, count = htmlbody:gsub(close_tag, '') 241 | -- Ensure we close tags 242 | if count < open_tags then 243 | htmlbody = htmlbody .. close_tag 244 | end 245 | return htmlbody 246 | end 247 | return s 248 | end 249 | 250 | local function irc_formatting_to_html(s) 251 | -- TODO, support foreground and background? 252 | local ct = {'white','black','blue','green','red','maroon','purple', 253 | 'orange','yellow','lightgreen','teal','cyan', 'lightblue', 254 | 'fuchsia', 'gray', 'lightgray'} 255 | 256 | s = byte_to_tag(s, '\02', '', '') 257 | s = byte_to_tag(s, '\029', '', '') 258 | s = byte_to_tag(s, '\031', '', '') 259 | -- First do full color strings with reset. 260 | -- Iterate backwards to catch long colors before short 261 | for i=#ct,1,-1 do 262 | s = s:gsub( 263 | '\003'..tostring(i-1)..'(.-)\003', 264 | '%1') 265 | end 266 | 267 | -- Then replace unmatch colors 268 | -- Iterate backwards to catch long colors before short 269 | for i=#ct,1,-1 do 270 | local c = ct[i] 271 | s = byte_to_tag(s, '\003'..tostring(i-1), 272 | '', '') 273 | end 274 | return s 275 | end 276 | 277 | local function strip_irc_formatting(s) 278 | if not s then return '' end 279 | return (s 280 | :gsub("\02", "") 281 | :gsub("\03%d%d?,%d%d?", "") 282 | :gsub("\03%d%d?", "") 283 | :gsub("\03", "") 284 | :gsub("\15", "") 285 | :gsub("\17", "") 286 | :gsub("\18", "") 287 | :gsub("\22", "") 288 | :gsub("\29", "") 289 | :gsub("\31", "")) 290 | end 291 | 292 | local function irc_formatting_to_weechat_color(s) 293 | -- TODO, support foreground and background? 294 | -- - is atribute to remove formatting 295 | -- | is to keep formatting during color changes 296 | s = byte_to_tag(s, '\02', w.color'bold', w.color'-bold') 297 | s = byte_to_tag(s, '\029', w.color'italic', w.color'-italic') 298 | s = byte_to_tag(s, '\031', w.color'underline', w.color'-underline') 299 | -- backwards to catch long numbers before short 300 | for i=16,1,-1 do 301 | i = tostring(i) 302 | s = byte_to_tag(s, '\003'..i, 303 | w.color("|"..i), w.color("-"..i)) 304 | end 305 | return s 306 | end 307 | 308 | function matrix_unload() 309 | w.print('', 'matrix: Unloading') 310 | -- Clear/free olm memory if loaded 311 | if olmstatus then 312 | w.print('', 'matrix: Saving olm state') 313 | SERVER.olm:save() 314 | w.print('', 'matrix: Clearing olm state from memory') 315 | SERVER.olm.account:clear() 316 | --SERVER.olm = nil 317 | end 318 | w.print('', 'matrix: done cleaning up!') 319 | return w.WEECHAT_RC_OK 320 | end 321 | 322 | local function wconf(optionname) 323 | return w.config_string(w.config_get(optionname)) 324 | end 325 | 326 | local function wcolor(optionname) 327 | return w.color(wconf(optionname)) 328 | end 329 | 330 | function command_help(current_buffer, args) 331 | if args then 332 | local help_cmds = {args=args} 333 | if not help_cmds then 334 | w.print("", "Command not found: " .. args) 335 | return 336 | end 337 | for cmd, helptext in pairs(help_cmds) do 338 | w.print('', w.color("bold") .. cmd) 339 | w.print('', (helptext or 'No help text').strip()) 340 | w.print('', '') 341 | end 342 | end 343 | 344 | end 345 | 346 | function command_connect(current_buffer, args) 347 | if not SERVER.connected then 348 | SERVER:connect() 349 | end 350 | return w.WEECHAT_RC_OK 351 | end 352 | 353 | function matrix_command_cb(data, current_buffer, args) 354 | if args == 'connect' then 355 | return command_connect(current_buffer) 356 | elseif args == 'debug' then 357 | if DEBUG then 358 | DEBUG = false 359 | w.print('', SCRIPT_NAME..': debugging messages disabled') 360 | else 361 | DEBUG = true 362 | w.print('', SCRIPT_NAME..': debugging messages enabled') 363 | end 364 | elseif args:match('^msg ') then 365 | local _ 366 | _, args = split_args(args) -- remove cmd 367 | local roomarg, msg = split_args(args) 368 | local room 369 | for id, r in pairs(SERVER.rooms) do 370 | -- Send /msg to a ID 371 | if id == roomarg then 372 | room = r 373 | break 374 | elseif roomarg == r.name then 375 | room = r 376 | break 377 | elseif roomarg == r.roomname then 378 | room = r 379 | break 380 | end 381 | end 382 | 383 | if room then 384 | room:Msg(msg) 385 | return w.WEECHAT_RC_OK_EAT 386 | end 387 | else 388 | perr("Command not found: " .. args) 389 | end 390 | 391 | return w.WEECHAT_RC_OK 392 | end 393 | 394 | function matrix_away_command_run_cb(data, buffer, args) 395 | -- Callback for command /away -all 396 | local _ 397 | _, args = split_args(args) -- remove cmd 398 | local msg 399 | _, msg = split_args(args) 400 | w.buffer_set(BUFFER, "localvar_set_away", msg) 401 | for id, room in pairs(SERVER.rooms) do 402 | if msg and msg ~= '' then 403 | w.buffer_set(room.buffer, "localvar_set_away", msg) 404 | else 405 | -- Delete takes empty string, and not nil 406 | w.buffer_set(room.buffer, "localvar_del_away", '') 407 | end 408 | end 409 | if msg and msg ~= '' then 410 | SERVER:SendPresence('unavailable', msg) 411 | mprint 'You have been marked as unavailable' 412 | else 413 | SERVER:SendPresence('online', nil) 414 | mprint 'You have been marked as online' 415 | end 416 | return w.WEECHAT_RC_OK 417 | end 418 | 419 | function configuration_changed_cb(data, option, value) 420 | if option == 'plugins.var.lua.matrix.timeout' then 421 | timeout = tonumber(value)*1000 422 | elseif option == 'plugins.var.lua.matrix.debug' then 423 | if value == 'on' then 424 | DEBUG = true 425 | w.print('', SCRIPT_NAME..': debugging messages enabled') 426 | else 427 | DEBUG = false 428 | w.print('', SCRIPT_NAME..': debugging messages disabled') 429 | end 430 | end 431 | end 432 | 433 | local function http(url, post, cb, h_timeout, extra, api_ns) 434 | if not post then 435 | post = {} 436 | end 437 | if not cb then 438 | cb = 'http_cb' 439 | end 440 | if not h_timeout then 441 | h_timeout = 60*1000 442 | end 443 | if not extra then 444 | extra = nil 445 | end 446 | if not api_ns then 447 | api_ns = "_matrix/client/r0" 448 | end 449 | 450 | -- Add accept encoding by default if it's not already there 451 | if not post.accept_encoding then 452 | post.accept_encoding = 'application/json' 453 | end 454 | if not post.header then 455 | post.header = 1 -- Request http headers in the response 456 | end 457 | 458 | if not url:match'https?://' then 459 | local homeserver_url = w.config_get_plugin('homeserver_url') 460 | homeserver_url = homeserver_url .. api_ns 461 | url = homeserver_url .. url 462 | end 463 | 464 | if DEBUG then 465 | dbg{request={ 466 | url=accesstoken_redact(url), 467 | post=post,extra=extra} 468 | } 469 | end 470 | w.hook_process_hashtable('url:' .. url, post, h_timeout, cb, extra) 471 | end 472 | 473 | local function parse_http_statusline(line) 474 | -- Attempt to match HTTP/1.0 or HTTP/1.1 475 | local httpversion, status_code, reason_phrase = line:match("^HTTP/(1%.[01]) (%d%d%d) (.-)\r?\n") 476 | if not httpversion then 477 | -- Attempt to match HTTP/1 or HTTP/2 if previous match fell through 478 | httpversion, status_code, reason_phrase = line:match("^HTTP/([12]) (%d%d%d) (.-)\r?\n") 479 | if not httpversion then 480 | return 481 | end 482 | end 483 | return httpversion, tonumber(status_code), reason_phrase 484 | end 485 | 486 | function real_http_cb(extra, command, rc, stdout, stderr) 487 | if DEBUG then 488 | dbg{reply={ 489 | command=accesstoken_redact(command), 490 | extra=extra,rc=rc,stdout=stdout,stderr=stderr} 491 | } 492 | end 493 | 494 | if stderr and stderr ~= '' then 495 | mprint(('error: %s'):format(accesstoken_redact(stderr))) 496 | return w.WEECHAT_RC_OK 497 | end 498 | 499 | -- Because of a bug in WeeChat sometimes the stdout gets prepended by 500 | -- any number of BEL chars (hex 07). Let's have a nasty workaround and 501 | -- just replace them away. 502 | if WEECHAT_VERSION < 0x01030000 then -- fixed in 1.3 503 | stdout = (stdout:gsub('^\007*', '')) 504 | end 505 | 506 | if stdout ~= '' then 507 | if not STDOUT[command] then 508 | STDOUT[command] = {} 509 | end 510 | table.insert(STDOUT[command], stdout) 511 | end 512 | 513 | if tonumber(rc) >= 0 then 514 | stdout = table.concat(STDOUT[command] or {}) 515 | STDOUT[command] = nil 516 | local httpversion, status_code, reason_phrase = parse_http_statusline(stdout) 517 | if not httpversion then 518 | perr(('Invalid http request: %s'):format(stdout)) 519 | return w.WEECHAT_RC_OK 520 | end 521 | if status_code == 504 and command:find'/sync' then -- keep hammering to try to get in as the server will keep slowly generating the response 522 | SERVER:initial_sync() 523 | return w.WEECHAT_RC_OK 524 | end 525 | if status_code >= 500 then 526 | perr(('HTTP API returned error. Code: %s, reason: %s'):format(status_code, reason_phrase)) 527 | return w.WEECHAT_RC_OK 528 | end 529 | -- Skip to data 530 | stdout = (stdout:match('.-\r?\n\r?\n(.*)')) 531 | -- Protected call in case of JSON errors. 'json.new()' ensures that locale is detected correctly (fixes bug #49) 532 | local success, js = pcall(json.new().decode, stdout) 533 | if not success then 534 | mprint(('error\t%s during json load: %s'):format(js, stdout)) 535 | return w.WEECHAT_RC_OK 536 | end 537 | if js['errcode'] or js['error'] then 538 | if command:find'login' then 539 | w.print('', ('matrix: Error code during login: %s, code: %s'):format( 540 | js['error'], js['errcode'])) 541 | w.print('', 'matrix: Please verify your username and password') 542 | else 543 | perr('API call returned error: '..js['error'] .. '('..tostring(js.errcode)..')') 544 | end 545 | return w.WEECHAT_RC_OK 546 | end 547 | -- Get correct handler 548 | if command:find('login') then 549 | for k, v in pairs(js) do 550 | SERVER[k] = v 551 | end 552 | SERVER.connected = true 553 | SERVER:initial_sync() 554 | elseif command:find'/rooms/.*/initialSync' then 555 | local myroom = SERVER:addRoom(js) 556 | for _, chunk in ipairs(js['presence']) do 557 | myroom:ParseChunk(chunk, true, 'presence') 558 | end 559 | for _, chunk in ipairs(js['messages']['chunk']) do 560 | myroom:ParseChunk(chunk, true, 'messages') 561 | end 562 | elseif command:find'/sync' then 563 | SERVER.end_token = js.next_batch 564 | 565 | -- We have a new end token, which means we safely can release the 566 | -- poll lock 567 | SERVER.poll_lock = false 568 | 569 | local backlog = false 570 | local initial = false 571 | if extra == 'initial' then 572 | initial = true 573 | backlog = true 574 | end 575 | 576 | -- Start with setting the global presence variable on the server 577 | -- so when the nicks get added to the room they can get added to 578 | -- the correct nicklist group according to if they have presence 579 | -- or not 580 | for _, e in ipairs(js.presence.events) do 581 | SERVER:UpdatePresence(e) 582 | end 583 | for membership, rooms in pairs(js['rooms']) do 584 | -- If we left the room, simply ignore it 585 | if membership ~= 'leave' or (membership == 'leave' and (not backlog)) then 586 | for identifier, room in pairs(rooms) do 587 | -- Monkey patch it to look like v1 object 588 | room.room_id = identifier 589 | local myroom 590 | if initial then 591 | myroom = SERVER:addRoom(room) 592 | else 593 | myroom = SERVER.rooms[identifier] 594 | -- Chunk for non-existing room 595 | if not myroom then 596 | myroom = SERVER:addRoom(room) 597 | if not membership == 'invite' then 598 | perr('Event for unknown room') 599 | end 600 | end 601 | end 602 | -- First of all parse invite states. 603 | local inv_states = room.invite_state 604 | if inv_states then 605 | local chunks = room.invite_state.events or {} 606 | for _, chunk in ipairs(chunks) do 607 | myroom:ParseChunk(chunk, backlog, 'states') 608 | end 609 | end 610 | -- Parse states before messages so we can add nicks and stuff 611 | -- before messages start appearing 612 | local states = room.state 613 | if states then 614 | local chunks = room.state.events or {} 615 | for _, chunk in ipairs(chunks) do 616 | myroom:ParseChunk(chunk, backlog, 'states') 617 | end 618 | end 619 | local timeline = room.timeline 620 | if timeline then 621 | -- Save the prev_batch on the initial message so we 622 | -- know for later when we picked up the sync 623 | if initial then 624 | myroom.prev_batch = timeline.prev_batch 625 | end 626 | local chunks = timeline.events or {} 627 | for _, chunk in ipairs(chunks) do 628 | myroom:ParseChunk(chunk, backlog, 'messages') 629 | end 630 | end 631 | local ephemeral = room.ephemeral 632 | -- Ignore Ephemeral Events during initial sync 633 | if (extra and extra ~= 'initial') and ephemeral then 634 | local chunks = ephemeral.events or {} 635 | for _, chunk in ipairs(chunks) do 636 | myroom:ParseChunk(chunk, backlog, 'states') 637 | end 638 | end 639 | local account_data = room.account_data 640 | if account_data then 641 | -- looks for m.fully_read event 642 | local chunks = account_data.events or {} 643 | for _, chunk in ipairs(chunks) do 644 | myroom:ParseChunk(chunk, backlog, 'account_data') 645 | end 646 | end 647 | if backlog then 648 | -- All the state should be done. Try to get a good name for the room now. 649 | myroom:SetName(myroom.identifier) 650 | end 651 | end 652 | end 653 | end 654 | -- Now we have created rooms and can go over the rooms and update 655 | -- the presence for each nick 656 | for _, e in pairs(js.presence.events) do 657 | SERVER:UpdatePresence(e) 658 | end 659 | if initial then 660 | SERVER:post_initial_sync() 661 | end 662 | SERVER:poll() 663 | elseif command:find'messages' then 664 | local identifier = extra 665 | local myroom = SERVER.rooms[identifier] 666 | myroom.prev_batch = js['end'] 667 | -- Freeze buffer 668 | myroom:Freeze() 669 | -- Clear buffer 670 | myroom:Clear() 671 | -- We request backwards direction, so iterate backwards 672 | for i=#js.chunk,1,-1 do 673 | local chunk = js.chunk[i] 674 | myroom:ParseChunk(chunk, true, 'messages') 675 | end 676 | -- Thaw! 677 | myroom:Thaw() 678 | elseif command:find'/join/' then 679 | -- We came from a join command, fecth some messages 680 | local found = false 681 | for id, _ in pairs(SERVER.rooms) do 682 | if id == js.room_id then 683 | found = true 684 | -- this is a false positive for example when getting 685 | -- invited. need to investigate more 686 | --mprint('error\tJoined room, but already in it.') 687 | break 688 | end 689 | end 690 | if not found then 691 | local data = urllib.urlencode({ 692 | access_token= SERVER.access_token, 693 | --limit= w.config_get_plugin('backlog_lines'), 694 | limit = 10, 695 | }) 696 | http(('/rooms/%s/initialSync?%s'):format( 697 | urllib.quote(js.room_id), data)) 698 | end 699 | elseif command:find'leave' then 700 | -- We store room_id in extra 701 | local room_id = extra 702 | SERVER:delRoom(room_id) 703 | elseif command:find'/keys/claim' then 704 | local count = 0 705 | for user_id, v in pairs(js.one_time_keys or {}) do 706 | for device_id, keys in pairs(v or {}) do 707 | for key_id, key in pairs(keys or {}) do 708 | SERVER.olm.otks[user_id..':'..device_id] = {[device_id]=key} 709 | perr(('olm: Recieved OTK for user %s for device id %s'):format(user_id, device_id)) 710 | count = count + 1 711 | SERVER.olm:create_session(user_id, device_id) 712 | end 713 | end 714 | end 715 | elseif command:find'/keys/query' then 716 | for k, v in pairs(js.device_keys or {}) do 717 | SERVER.olm.device_keys[k] = v 718 | 719 | -- Claim keys for all only if missing session 720 | for device_id, device_data in pairs(v) do 721 | -- First try to create session from saved data 722 | -- if that doesn't success we will download otk 723 | local device_key = device_data.keys['curve25519:'..device_id] 724 | local sessions = SERVER.olm:get_sessions(device_key) 725 | if #sessions == 0 then 726 | perr('olm: Downloading otk for user '..k..', and device_id: '..device_id) 727 | SERVER.olm:claim(k, device_id) 728 | else 729 | perr('olm: Reusing existing session for user '..k) 730 | end 731 | end 732 | end 733 | elseif command:find'/keys/upload' then 734 | local key_count = 0 735 | local sensible_number_of_keys = 20 736 | for algo, count in pairs(js.one_time_key_counts) do 737 | key_count = count 738 | SERVER.olm.key_count = key_count 739 | end 740 | if DEBUG then 741 | perr('olm: Number of own OTKs uploaded to server: '..key_count) 742 | end 743 | -- TODO make endless loop prevention in case of server error 744 | if key_count < sensible_number_of_keys then 745 | SERVER.olm:upload_keys() 746 | end 747 | elseif command:find'upload' then 748 | -- We store room_id in extra 749 | local room_id = extra 750 | if js.content_uri then 751 | SERVER:Msg(room_id, js.content_uri) 752 | end 753 | -- luacheck: ignore 542 754 | elseif command:find'/typing/' then 755 | -- either it errs or it is empty 756 | elseif command:find'/state/' then 757 | -- TODO errorcode: M_FORBIDDEN 758 | -- either it errs or it is empty 759 | --dbg({state= js}) 760 | elseif command:find'/send/' then 761 | -- XXX Errorhandling 762 | -- TODO save event id to use for localecho 763 | local event_id = js.event_id 764 | local room_id = extra 765 | -- When using relay client, WeeChat doesn't get any buffer_switch 766 | -- signals, and thus cannot know when the relay client reads any 767 | -- messages. https://github.com/weechat/weechat/issues/180 768 | -- As a better than nothing approach we send read receipt when 769 | -- user sends a message, since most likely the user has read 770 | -- messages in that room if sending messages to it. 771 | SERVER:SendReadMarker(room_id, event_id) 772 | elseif command:find'createRoom' then 773 | -- We get join events, so we don't have to do anything 774 | elseif command:find'/publicRooms' then 775 | mprint 'Public rooms:' 776 | mprint '\tUsers\tName\tTopic\tAliases' 777 | table.sort(js.chunk, function(a, b) 778 | return a.num_joined_members > b.num_joined_members 779 | end) 780 | for _, r in ipairs(js.chunk) do 781 | local name = '' 782 | if r.name and r.name ~= json.null then 783 | name = r.name:gsub('\n', '') 784 | end 785 | local topic = '' 786 | if r.topic and r.topic ~= json.null then 787 | topic = r.topic:gsub('\n', '') 788 | end 789 | mprint(('%s %s %s %s') 790 | :format( 791 | r.num_joined_members or '', 792 | name or '', 793 | topic or '', 794 | table.concat(r.aliases or {}, ', '))) 795 | end 796 | -- luacheck: ignore 542 797 | elseif command:find'/invite' then 798 | elseif command:find'receipt' then 799 | -- we don't care about receipts for now 800 | elseif command:find'read_markers' then 801 | -- we don't care about read markers for now 802 | elseif command:find'directory/room' then 803 | --- XXX: parse result 804 | mprint 'Created new alias for room' 805 | elseif command:match'presence/.*/status' then 806 | -- Return of SendPresence which we don't have to handle because 807 | -- it will be sent back to us as an event 808 | else 809 | dbg{['error'] = {msg='Unknown command in http cb', command=accesstoken_redact(command), 810 | js=js}} 811 | end 812 | end 813 | 814 | if tonumber(rc) == -2 then -- -2 == WEECHAT_HOOK_PROCESS_ERROR 815 | perr(('Call to API errored in command %s, maybe timeout?'):format( 816 | accesstoken_redact(command))) 817 | -- Empty cache in case of errors 818 | if STDOUT[command] then 819 | STDOUT[command] = nil 820 | end 821 | -- Release poll lock in case of errors 822 | SERVER.poll_lock = false 823 | end 824 | 825 | return w.WEECHAT_RC_OK 826 | end 827 | 828 | function http_cb(data, command, rc, stdout, stderr) 829 | local status, result = pcall(real_http_cb, data, command, rc, stdout, stderr) 830 | if not status then 831 | perr('Error in http_cb: ' .. tostring(result)) 832 | perr(debug.traceback()) 833 | end 834 | return result 835 | end 836 | 837 | function upload_cb(data, command, rc, stdout, stderr) 838 | local success, js = pcall(json.decode, stdout) 839 | if not success then 840 | mprint(('error\t%s when getting uploaded URI: %s'):format(js, stdout)) 841 | return w.WEECHAT_RC_OK 842 | end 843 | 844 | local uri = js.content_uri 845 | if not uri then 846 | mprint(('error\tNo content_uri after upload. Stdout: %s, stderr: %s'):format(stdout, stderr)) 847 | return w.WEECHAT_RC_OK 848 | end 849 | 850 | local room_id = data 851 | local body = 'Image' 852 | local msgtype = 'm.image' 853 | SERVER:Msg(room_id, body, msgtype, uri) 854 | 855 | return w.WEECHAT_RC_OK 856 | end 857 | 858 | Olm = {} 859 | Olm.__index = Olm 860 | Olm.create = function() 861 | local olmdata = {} 862 | setmetatable(olmdata, Olm) 863 | if not olmstatus then 864 | w.print('', SCRIPT_NAME .. ': Unable to load olm encryption library. Not enabling encryption. Please see documentation (README.md) for information on how to enable.') 865 | return 866 | end 867 | 868 | local account = olm.Account.new() 869 | olmdata.account = account 870 | olmdata.sessions = {} 871 | olmdata.device_keys = {} 872 | olmdata.otks = {} 873 | -- Try to read account from filesystem, if not generate a new account 874 | local fd = io.open(HOMEDIR..'account.olm', 'rb') 875 | local pickled = '' 876 | if fd then 877 | pickled = fd:read'*all' 878 | fd:close() 879 | end 880 | if pickled == '' then 881 | account:create() 882 | local _, err = account:generate_one_time_keys(5) 883 | perr(err) 884 | else 885 | local _, err = account:unpickle(OLM_KEY, pickled) 886 | perr(err) 887 | end 888 | local identity = json.decode(account:identity_keys()) 889 | -- TODO figure out what device id is supposed to be 890 | olmdata.device_id = identity.ed25519:match'%w*' -- problems with nonalfanum 891 | olmdata.device_key = identity.curve25519 892 | w.print('', 'matrix: Encryption loaded. To send encrypted messages in a room, use command /encrypt on with a room as active current buffer') 893 | if DEBUG then 894 | dbg{olm={ 895 | 'Loaded identity:', 896 | json.decode(account:identity_keys()) 897 | }} 898 | end 899 | return olmdata 900 | end 901 | 902 | function Olm:save() 903 | -- Save account and every pickled session 904 | local pickle, err = self.account:pickle(OLM_KEY) 905 | perr(err) 906 | local fd = io.open(HOMEDIR..'account.olm', 'wb') 907 | fd:write(pickle) 908 | fd:close() 909 | --for key, pickled in pairs(self.sessions) do 910 | -- local user_id, device_id = key:match('(.*):(.+)') 911 | -- self.write_session_to_file(pickled, user_id, device_id) 912 | --end 913 | end 914 | 915 | function Olm:query(user_ids) -- Query keys from other user_id 916 | if DEBUG then 917 | perr('olm: querying user_ids') 918 | tprint(user_ids) 919 | end 920 | local auth = urllib.urlencode{access_token=SERVER.access_token} 921 | local data = { 922 | device_keys = {} 923 | } 924 | for _, uid in pairs(user_ids) do 925 | data.device_keys[uid] = {false} 926 | end 927 | http('/keys/query/?'..auth, 928 | {postfields=json.encode(data)}, 929 | 'http_cb', 930 | timeout, nil, 931 | v2_api_ns 932 | ) 933 | end 934 | 935 | function Olm:check_server_keycount() 936 | local data = urllib.urlencode{access_token=SERVER.access_token} 937 | http('/keys/upload/'..self.device_id..'?'..data, 938 | {}, 939 | 'http_cb', timeout, nil, v2_api_ns 940 | ) 941 | end 942 | 943 | function Olm:upload_keys() 944 | if DEBUG then 945 | perr('olm: Uploading keys') 946 | end 947 | local id_keys = json.decode(self.account:identity_keys()) 948 | local user_id = SERVER.user_id 949 | local one_time_keys = {} 950 | local otks = json.decode(self.account:one_time_keys()) 951 | local keyCount = 0 952 | for id, k in pairs(otks.curve25519) do 953 | keyCount = keyCount + 1 954 | end 955 | perr('olm: keycount: '..tostring(keyCount)) 956 | if keyCount < 5 then -- try to always have 5 keys 957 | perr('olm: newly generated keys: '..tostring(tonumber( 958 | self.account:generate_one_time_keys(5 - keyCount)))) 959 | otks = json.decode(self.account:one_time_keys()) 960 | end 961 | 962 | for id, key in pairs(otks.curve25519) do 963 | one_time_keys['curve25519:'..id] = key 964 | keyCount = keyCount + 1 965 | end 966 | 967 | -- Construct JSON manually so it's ready for signing 968 | local keys_json = '{"algorithms":["' .. OLM_ALGORITHM .. '"]' 969 | .. ',"device_id":"' .. self.device_id .. '"' 970 | .. ',"keys":' 971 | .. '{"ed25519:' .. self.device_id .. '":"' 972 | .. id_keys.ed25519 973 | .. '","curve25519:' .. self.device_id .. '":"' 974 | .. id_keys.curve25519 975 | .. '"}' 976 | .. ',"user_id":"' .. user_id 977 | .. '"}' 978 | 979 | local success, key_data = pcall(json.decode, keys_json) 980 | -- TODO save key data to device_keys so we don't have to download 981 | -- our own keys from the servers? 982 | if not success then 983 | perr(('olm: upload_keys: %s when converting to json: %s') 984 | :format(key_data, keys_json)) 985 | end 986 | 987 | local msg = { 988 | device_keys = key_data, 989 | one_time_keys = one_time_keys 990 | } 991 | msg.device_keys.signatures = { 992 | [user_id] = { 993 | ["ed25519:"..self.device_id] = self.account:sign(keys_json) 994 | } 995 | } 996 | local data = urllib.urlencode{ 997 | access_token = SERVER.access_token 998 | } 999 | http('/keys/upload/'..self.device_id..'?'..data, { 1000 | postfields = json.encode(msg) 1001 | }, 'http_cb', timeout, nil, v2_api_ns) 1002 | 1003 | self.account:mark_keys_as_published() 1004 | 1005 | end 1006 | 1007 | function Olm:claim(user_id, device_id) -- Fetch one time keys 1008 | if DEBUG then 1009 | perr(('olm: Claiming OTK for user: %s and device: %s'):format(user_id, device_id)) 1010 | end 1011 | -- TODO take a list of ids for batch downloading 1012 | local auth = urllib.urlencode{ access_token = SERVER.access_token } 1013 | local data = { 1014 | one_time_keys = { 1015 | [user_id] = { 1016 | [device_id] = 'curve25519' 1017 | } 1018 | } 1019 | } 1020 | http('/keys/claim?'..auth, 1021 | {postfields=json.encode(data)}, 1022 | 'http_cb', timeout, nil, v2_api_ns 1023 | ) 1024 | end 1025 | 1026 | function Olm:create_session(user_id, device_id) 1027 | perr(('olm: creating session for user: %s, and device: %s'):format(user_id, device_id)) 1028 | local device_data = self.device_keys[user_id][device_id] 1029 | if not device_data then 1030 | perr(('olm: missing device data for user: %s, and device: %s'):format(user_id, device_id)) 1031 | return 1032 | end 1033 | local device_key = device_data.keys['curve25519:'..device_id] 1034 | if not device_key then 1035 | perr("olm: Missing key for user: "..user_id.." and device: "..device_id.."") 1036 | return 1037 | end 1038 | local sessions = self:get_sessions(device_key) 1039 | if not sessions[device_key] then 1040 | perr(('olm: creating NEW session for: %s, and device: %s'):format(user_id, device_id)) 1041 | local session = olm.Session.new() 1042 | local otk = self.otks[user_id..':'..device_id] 1043 | if not otk then 1044 | perr("olm: Missing OTK for user: "..user_id.." and device: "..device_id.."") 1045 | else 1046 | otk = otk[device_id] 1047 | end 1048 | if otk then 1049 | session:create_outbound(self.account, device_key, otk) 1050 | local session_id = session:session_id() 1051 | perr('olm: Session ID:'..tostring(session_id)) 1052 | self:store_session(device_key, session) 1053 | end 1054 | session:clear() 1055 | end 1056 | end 1057 | 1058 | function Olm:get_sessions(device_key) 1059 | if DEBUG then 1060 | perr("olm: get_sessions: device: "..device_key.."") 1061 | end 1062 | local sessions = self.sessions[device_key] 1063 | if not sessions then 1064 | sessions = self:read_session(device_key) 1065 | end 1066 | return sessions 1067 | end 1068 | 1069 | function Olm:read_session(device_key) 1070 | local session_filename = HOMEDIR..device_key..'.session.olm' 1071 | local fd, err = io.open(session_filename, 'rb') 1072 | if fd then 1073 | perr(('olm: reading saved session device: %s'):format(device_key)) 1074 | local sessions = fd:read'*all' 1075 | sessions = json.decode(sessions) 1076 | self.sessions[device_key] = sessions 1077 | fd:close() 1078 | return sessions 1079 | else 1080 | perr(('olm: Error: %s, reading saved session device: %s'):format(err, device_key)) 1081 | end 1082 | return {} 1083 | end 1084 | 1085 | function Olm:store_session(device_key, session) 1086 | local session_id = session:session_id() 1087 | if DEBUG then 1088 | perr("olm: store_session: device: "..device_key..", Session ID: "..session_id) 1089 | end 1090 | local sessions = self.sessions[device_key] or {} 1091 | local pickled = session:pickle(OLM_KEY) 1092 | sessions[session_id] = pickled 1093 | self.sessions[device_key] = sessions 1094 | self:write_session_to_file(sessions, device_key) 1095 | end 1096 | 1097 | function Olm:write_session_to_file(sessions, device_key) 1098 | local session_filename = HOMEDIR..device_key..'.session.olm' 1099 | local fd, err = io.open(session_filename, 'wb') 1100 | if fd then 1101 | fd:write(json.encode(sessions)) 1102 | fd:close() 1103 | else 1104 | perr('olm: error saving session: '..tostring(err)) 1105 | end 1106 | end 1107 | 1108 | MatrixServer = {} 1109 | MatrixServer.__index = MatrixServer 1110 | 1111 | MatrixServer.create = function() 1112 | local server = {} 1113 | setmetatable(server, MatrixServer) 1114 | server.nick = nil 1115 | server.connecting = false 1116 | server.connected = false 1117 | server.rooms = {} 1118 | -- Store user presences here since they are not local to the rooms 1119 | server.presence = {} 1120 | server.avatars = {} 1121 | server.end_token = 'END' 1122 | server.typing_time = os.time() 1123 | if w.config_get_plugin('presence_filter') ~= 'on' then 1124 | server.typingtimer = w.hook_timer(10*1000, 0, 0, "cleartyping", "") 1125 | end 1126 | 1127 | -- Use a lock to prevent multiple simul poll with same end token, which 1128 | -- could lead to duplicate messages 1129 | server.poll_lock = false 1130 | server.olm = Olm.create() 1131 | if server.olm then -- might not be available 1132 | -- Run save so we do not lose state. Create might create new account, 1133 | -- new keys, etc. 1134 | server.olm:save() 1135 | end 1136 | return server 1137 | end 1138 | 1139 | function MatrixServer:UpdatePresence(c) 1140 | local user_id = c.sender or c.content.user_id 1141 | self.presence[user_id] = c.content.presence 1142 | for id, room in pairs(self.rooms) do 1143 | room:UpdatePresence(c.sender, c.content.presence) 1144 | end 1145 | end 1146 | 1147 | function MatrixServer:findRoom(buffer_ptr) 1148 | for id, room in pairs(self.rooms) do 1149 | if room.buffer == buffer_ptr then 1150 | return room 1151 | end 1152 | end 1153 | end 1154 | 1155 | function MatrixServer:connect() 1156 | if not self.connecting then 1157 | local user = weechat_eval(w.config_get_plugin('user')) 1158 | local password = weechat_eval(w.config_get_plugin('password')) 1159 | if user == '' or password == '' then 1160 | w.print('', 'Please set your username and password using the settings system and then type /matrix connect') 1161 | return 1162 | end 1163 | 1164 | self.connecting = true 1165 | w.print('', 'matrix: Connecting to homeserver URL: '.. 1166 | w.config_get_plugin('homeserver_url')) 1167 | local pattern = "^[%w.]+@%w+%.%w+$" 1168 | local medium 1169 | if string.match(user, pattern) then 1170 | medium = "email" 1171 | else 1172 | medium = "user" 1173 | end 1174 | local post = { 1175 | ["user"]=user, 1176 | ["identifier.address"]=user, 1177 | ["identifier.medium"]=medium, 1178 | ["identifier.type"]="m.id.thirdparty", 1179 | ["initial_device_display_name"]="WeeMatrix", 1180 | ["medium"]=medium, 1181 | ["password"]=password, 1182 | ["type"]="m.login.password" 1183 | } 1184 | http('/login', { 1185 | postfields = json.encode(post) 1186 | }, 'http_cb', timeout) 1187 | end 1188 | end 1189 | 1190 | function MatrixServer:initial_sync() 1191 | BUFFER = w.buffer_new(SCRIPT_NAME, "", "", "closed_matrix_buffer_cb", "") 1192 | w.buffer_set(BUFFER, "short_name", SCRIPT_NAME) 1193 | w.buffer_set(BUFFER, "name", SCRIPT_NAME) 1194 | w.buffer_set(BUFFER, "localvar_set_type", "server") 1195 | w.buffer_set(BUFFER, "localvar_set_server", SCRIPT_NAME) 1196 | w.buffer_set(BUFFER, "title", ("Matrix: %s"):format( 1197 | w.config_get_plugin'homeserver_url')) 1198 | if w.config_string(w.config_get('irc.look.server_buffer')) == 'merge_with_core' then 1199 | w.buffer_merge(BUFFER, w.buffer_search_main()) 1200 | end 1201 | w.buffer_set(BUFFER, "display", "auto") 1202 | local data = urllib.urlencode({ 1203 | access_token = self.access_token, 1204 | timeout = 1000*POLL_INTERVAL, 1205 | full_state = 'true', 1206 | filter = json.encode({ -- timeline filter 1207 | room = { 1208 | timeline = { 1209 | limit = tonumber(w.config_get_plugin('backlog_lines')) 1210 | } 1211 | }, 1212 | presence = { 1213 | not_types = {'*'}, -- dont want presence 1214 | } 1215 | }) 1216 | }) 1217 | local extra = 'initial' 1218 | -- New v2 sync API is slow. Until we can easily ignore archived rooms 1219 | -- let's increase the timer for the initial login 1220 | local login_timer = 60*5*1000 1221 | http('/sync?'..data, nil, 'http_cb', login_timer, extra) 1222 | end 1223 | 1224 | function MatrixServer:post_initial_sync() 1225 | -- Timer used in cased of errors to restart the polling cycle 1226 | -- During normal operation the polling should re-invoke itself 1227 | SERVER.polltimer = w.hook_timer(POLL_INTERVAL*1000, 0, 0, "polltimer_cb", "") 1228 | if olmstatus then 1229 | -- timer that checks number of otks available on the server 1230 | SERVER.otktimer = w.hook_timer(5*60*1000, 0, 0, "otktimer_cb", "") 1231 | SERVER.olm:query{SERVER.user_id} 1232 | --SERVER.olm.upload_keys() 1233 | SERVER.olm:check_server_keycount() 1234 | end 1235 | end 1236 | 1237 | function MatrixServer:getMessages(room_id, dir, from, limit) 1238 | if not dir then dir = 'b' end 1239 | if not from then from = 'END' end 1240 | if not limit then limit = w.config_get_plugin('backlog_lines') end 1241 | local data = urllib.urlencode({ 1242 | access_token = self.access_token, 1243 | dir = dir, 1244 | from = from, 1245 | limit = limit, 1246 | }) 1247 | http(('/rooms/%s/messages?%s') 1248 | :format(urllib.quote(room_id), data), nil, nil, nil, room_id) 1249 | end 1250 | 1251 | function MatrixServer:Join(room) 1252 | if not self.connected then 1253 | --XXX''' 1254 | return 1255 | end 1256 | 1257 | mprint('\tJoining room '..room) 1258 | room = urllib.quote(room) 1259 | http('/join/' .. room, 1260 | {postfields = "access_token="..self.access_token}) 1261 | end 1262 | 1263 | function MatrixServer:part(room) 1264 | if not self.connected then 1265 | --XXX''' 1266 | return 1267 | end 1268 | 1269 | local id = urllib.quote(room.identifier) 1270 | local data = urllib.urlencode({ 1271 | access_token= self.access_token, 1272 | }) 1273 | http(('/rooms/%s/leave?%s'):format(id, data), {postfields = "{}"}, 1274 | 'http_cb', timeout, room.identifier) 1275 | end 1276 | 1277 | function MatrixServer:poll() 1278 | if self.connected == false then 1279 | return 1280 | end 1281 | if self.poll_lock then 1282 | return 1283 | end 1284 | self.poll_lock = true 1285 | self.polltime = os.time() 1286 | local filter = {} 1287 | if w.config_get_plugin('presence_filter') == 'on' then 1288 | filter = { -- timeline filter 1289 | presence = { 1290 | not_types = {'*'}, -- dont want presence 1291 | }, 1292 | room = { 1293 | ephemeral = { 1294 | not_types = {'*'}, -- dont want read receipt and typing notices 1295 | } 1296 | } 1297 | } 1298 | end 1299 | local data = urllib.urlencode({ 1300 | access_token = self.access_token, 1301 | timeout = 1000*POLL_INTERVAL, 1302 | full_state = 'false', 1303 | filter = json.encode(filter), 1304 | since = self.end_token 1305 | }) 1306 | http('/sync?'..data, nil, 'http_cb', (POLL_INTERVAL+10)*1000) 1307 | end 1308 | 1309 | function MatrixServer:addRoom(room) 1310 | -- Just in case, we check for duplicates here 1311 | if self.rooms[room['room_id']] then 1312 | return self.rooms[room['room_id']] 1313 | end 1314 | local myroom = Room.create(room) 1315 | myroom:create_buffer() 1316 | self.rooms[room['room_id']] = myroom 1317 | return myroom 1318 | end 1319 | 1320 | function MatrixServer:delRoom(room_id) 1321 | for id, room in pairs(self.rooms) do 1322 | if id == room_id then 1323 | mprint('\tLeft room '..room.name) 1324 | room:destroy() 1325 | self.rooms[id] = nil 1326 | break 1327 | end 1328 | end 1329 | end 1330 | 1331 | function MatrixServer:SendReadMarker(room_id, event_id) 1332 | -- Send read marker and read receipt too. 1333 | -- Read receipt is a federated event, read marker is only visible by the 1334 | -- user to by used by clients. 1335 | -- 1336 | -- TODO: prevent sending multiple identical read receipts 1337 | local auth = urllib.urlencode{access_token=self.access_token} 1338 | local url = '/rooms/'..room_id..'/read_markers?'..auth 1339 | local data = { 1340 | customrequest = 'POST', 1341 | postfields = {} 1342 | } 1343 | data.postfields['m.fully_read'] = event_id 1344 | 1345 | if w.config_get_plugin('read_receipts') == 'on' then 1346 | data.postfields['m.read'] = event_id 1347 | end 1348 | 1349 | data.postfields = json.encode(data.postfields) 1350 | http(url, 1351 | data, 1352 | 'http_cb', 1353 | timeout 1354 | ) 1355 | end 1356 | 1357 | function MatrixServer:Msg(room_id, body, msgtype, url) 1358 | -- check if there's an outgoing message timer already 1359 | self:ClearSendTimer() 1360 | 1361 | if not msgtype then 1362 | msgtype = 'm.text' 1363 | end 1364 | 1365 | if not OUT[room_id] then 1366 | OUT[room_id] = {} 1367 | end 1368 | -- Add message to outgoing queue of messages for this room 1369 | table.insert(OUT[room_id], {msgtype, body, url}) 1370 | 1371 | self:StartSendTimer() 1372 | end 1373 | 1374 | function MatrixServer:StartSendTimer() 1375 | local send_delay = 50 -- Wait this long for paste detection 1376 | self.sendtimer = w.hook_timer(send_delay, 0, 1, "send", "") 1377 | end 1378 | 1379 | function MatrixServer:ClearSendTimer() 1380 | -- Clear timer if it exists 1381 | if self.sendtimer then 1382 | w.unhook(self.sendtimer) 1383 | end 1384 | self.sendtimer = nil 1385 | end 1386 | 1387 | function send(cbdata, calls) 1388 | SERVER:ClearSendTimer() 1389 | -- Find the room 1390 | local room 1391 | 1392 | for id, msgs in pairs(OUT) do 1393 | -- Clear message 1394 | OUT[id] = nil 1395 | local body = {} 1396 | local htmlbody = {} 1397 | local msgtype 1398 | local url 1399 | 1400 | local ishtml = false 1401 | 1402 | for _, r in pairs(SERVER.rooms) do 1403 | if r.identifier == id then 1404 | room = r 1405 | break 1406 | end 1407 | end 1408 | 1409 | for _, msg in pairs(msgs) do 1410 | -- last msgtype will override any other for simplicity's sake 1411 | msgtype = msg[1] 1412 | local html = irc_formatting_to_html(msg[2]) 1413 | if html ~= msg[2] then 1414 | ishtml = true 1415 | end 1416 | table.insert(htmlbody, html ) 1417 | table.insert(body, msg[2] ) 1418 | if msg[3] then -- Primarily image upload 1419 | url = msg[3] 1420 | end 1421 | end 1422 | body = table.concat(body, '\n') 1423 | 1424 | -- Run IRC modifiers (XXX: maybe run out1 also? 1425 | body = w.hook_modifier_exec('irc_out1_PRIVMSG', '', body) 1426 | 1427 | if w.config_get_plugin('local_echo') == 'on' or 1428 | room.encrypted then 1429 | -- Generate local echo 1430 | local color = default_color 1431 | if msgtype == 'm.text' then 1432 | --- XXX: no localecho for encrypted messages? 1433 | local tags = 'notify_none,localecho,no_highlight' 1434 | if room.encrypted then 1435 | tags = tags .. ',no_log' 1436 | color = w.color(w.config_get_plugin( 1437 | 'encrypted_message_color')) 1438 | end 1439 | w.print_date_tags(room.buffer, nil, 1440 | tags, ("%s\t%s%s"):format( 1441 | room:formatNick(SERVER.user_id), 1442 | color, 1443 | irc_formatting_to_weechat_color(body) 1444 | ) 1445 | ) 1446 | elseif msgtype == 'm.emote' then 1447 | local prefix_c = wcolor'weechat.color.chat_prefix_action' 1448 | local prefix = wconf'weechat.look.prefix_action' 1449 | local tags = 'notify_none,localecho,irc_action,no_highlight' 1450 | if room.encrypted then 1451 | tags = tags .. ',no_log' 1452 | color = w.color(w.config_get_plugin( 1453 | 'encrypted_message_color')) 1454 | end 1455 | w.print_date_tags(room.buffer, nil, 1456 | tags, ("%s%s\t%s%s%s %s"):format( 1457 | prefix_c, 1458 | prefix, 1459 | w.color('chat_nick_self'), 1460 | room.users[SERVER.user_id], 1461 | color, 1462 | irc_formatting_to_weechat_color(body) 1463 | ) 1464 | ) 1465 | end 1466 | end 1467 | 1468 | local data = { 1469 | postfields = { 1470 | msgtype = msgtype, 1471 | body = body, 1472 | url = url, 1473 | }} 1474 | 1475 | if ishtml then 1476 | htmlbody = table.concat(htmlbody, '\n') 1477 | data.postfields.body = strip_irc_formatting(body) 1478 | data.postfields.format = 'org.matrix.custom.html' 1479 | data.postfields.formatted_body = htmlbody 1480 | end 1481 | 1482 | local api_event_function = 'm.room.message' 1483 | 1484 | if olmstatus and room.encrypted then 1485 | api_event_function = 'm.room.encrypted' 1486 | local olmd = SERVER.olm 1487 | 1488 | data.postfields.algorithm = OLM_ALGORITHM 1489 | data.postfields.sender_key = olmd.device_key 1490 | data.postfields.ciphertext = {} 1491 | 1492 | -- Count number of devices we are sending to 1493 | local recipient_count = 0 1494 | 1495 | for user_id, _ in pairs(room.users) do 1496 | for device_id, device_data in pairs(olmd.device_keys[user_id] or {}) do -- FIXME check for missing keys? 1497 | 1498 | local device_key 1499 | -- TODO save this better somehow? 1500 | for key_id, key_data in pairs(device_data.keys) do 1501 | if key_id:match('^curve25519') then 1502 | device_key = key_data 1503 | end 1504 | end 1505 | local sessions = olmd:get_sessions(device_key) 1506 | -- Use the session with the lowest ID 1507 | -- TODO: figure out how to pick session better? 1508 | table.sort(sessions) 1509 | local pickled = next(sessions) 1510 | if pickled then 1511 | local session = olm.Session.new() 1512 | session:unpickle(OLM_KEY, pickled) 1513 | local session_id = session:session_id() 1514 | perr(('Session ID: %s, user_id: %s, device_id: %s'): 1515 | format(session_id, user_id, device_id)) 1516 | local payload = { 1517 | room_id = room.identifier, 1518 | ['type'] = "m.room.message", 1519 | fingerprint = "", -- TODO: Olm:sha256 participants 1520 | sender_device = olmd.device_id, 1521 | content = { 1522 | msgtype = msgtype, 1523 | body = data.postfields.body or '', 1524 | url = url 1525 | } 1526 | } 1527 | -- encrypt body 1528 | local mtype, e_body = session:encrypt(json.encode(payload)) 1529 | local ciphertext = { 1530 | ["type"] = mtype, 1531 | body = e_body 1532 | } 1533 | data.postfields.ciphertext[device_key] = ciphertext 1534 | recipient_count = recipient_count + 1 1535 | 1536 | -- Save session 1537 | olmd:store_session(device_key, session) 1538 | session:clear() 1539 | end 1540 | end 1541 | end 1542 | -- remove cleartext from original msg 1543 | data.postfields.body = nil 1544 | data.postfields.formatted_body = nil 1545 | 1546 | if recipient_count == 0 then 1547 | perr('Aborted sending of encrypted message: could not find any valid recipients') 1548 | return 1549 | end 1550 | end 1551 | 1552 | data.postfields = json.encode(data.postfields) 1553 | data.customrequest = 'PUT' 1554 | 1555 | http(('/rooms/%s/send/%s/%s?access_token=%s') 1556 | :format( 1557 | urllib.quote(id), 1558 | api_event_function, 1559 | get_next_transaction_id(), 1560 | urllib.quote(SERVER.access_token) 1561 | ), 1562 | data, 1563 | nil, 1564 | nil, 1565 | id -- send room id to extra 1566 | ) 1567 | end 1568 | end 1569 | 1570 | function MatrixServer:emote(room_id, body) 1571 | self:Msg(room_id, body, 'm.emote') 1572 | end 1573 | 1574 | function MatrixServer:notice(room_id, body) 1575 | self:Msg(room_id, body, 'm.notice') 1576 | end 1577 | 1578 | function MatrixServer:state(room_id, key, data) 1579 | http(('/rooms/%s/state/%s?access_token=%s') 1580 | :format(urllib.quote(room_id), 1581 | urllib.quote(key), 1582 | urllib.quote(self.access_token)), 1583 | {customrequest = 'PUT', 1584 | postfields = json.encode(data), 1585 | }) 1586 | end 1587 | 1588 | function MatrixServer:set_membership(room_id, userid, data) 1589 | http(('/rooms/%s/state/m.room.member/%s?access_token=%s') 1590 | :format(urllib.quote(room_id), 1591 | urllib.quote(userid), 1592 | urllib.quote(self.access_token)), 1593 | {customrequest = 'PUT', 1594 | postfields = json.encode(data), 1595 | }) 1596 | end 1597 | 1598 | function MatrixServer:SendPresence(p, status_msg) 1599 | -- One of: ["online", "offline", "unavailable", "free_for_chat"] 1600 | local data = { 1601 | presence = p, 1602 | status_msg = status_msg 1603 | } 1604 | http(('/presence/%s/status?access_token=%s') 1605 | :format( 1606 | urllib.quote(self.user_id), 1607 | urllib.quote(self.access_token)), 1608 | {customrequest = 'PUT', 1609 | postfields = json.encode(data), 1610 | }) 1611 | end 1612 | 1613 | function MatrixServer:SendTypingNotice(room_id) 1614 | local data = { 1615 | typing = true, 1616 | timeout = 4*1000 1617 | } 1618 | http(('/rooms/%s/typing/%s?access_token=%s') 1619 | :format(urllib.quote(room_id), 1620 | urllib.quote(self.user_id), 1621 | urllib.quote(self.access_token)), 1622 | {customrequest = 'PUT', 1623 | postfields = json.encode(data), 1624 | }) 1625 | end 1626 | 1627 | function MatrixServer:Upload(room_id, filename) 1628 | local content_type = 'image/jpeg' 1629 | if filename:match'%.[Pp][nN][gG]$' then 1630 | content_type = 'image/png' 1631 | end 1632 | local url = w.config_get_plugin('homeserver_url') .. 1633 | ('_matrix/media/r0/upload?access_token=%s') 1634 | :format( urllib.quote(SERVER.access_token) ) 1635 | w.hook_process_hashtable('curl', { 1636 | arg1 = '--data-binary', -- no encoding of data 1637 | arg2 = '@'..filename, -- @means curl will load the filename 1638 | arg3 = '-XPOST', -- HTTP POST method 1639 | arg4 = '-H', -- header 1640 | arg5 = 'Content-Type: '..content_type, 1641 | arg6 = '-s', -- silent 1642 | arg7 = url, 1643 | }, 30*1000, 'upload_cb', room_id) 1644 | end 1645 | 1646 | function MatrixServer:CreateRoom(public, alias, invites) 1647 | local data = {} 1648 | if alias then 1649 | data.room_alias_name = alias 1650 | end 1651 | if public then 1652 | data.visibility = 'public' 1653 | else 1654 | data.visibility = 'private' 1655 | end 1656 | if invites then 1657 | data.invite = invites 1658 | end 1659 | http(('/createRoom?access_token=%s') 1660 | :format(urllib.quote(self.access_token)), 1661 | {customrequest = 'POST', 1662 | postfields = json.encode(data), 1663 | }) 1664 | end 1665 | 1666 | function MatrixServer:CreateRoomAlias(room_id, alias) 1667 | local data = {room_id = room_id} 1668 | alias = urllib.quote(alias) 1669 | http(('/directory/room/%s?access_token=%s') 1670 | :format(alias, urllib.quote(self.access_token)), 1671 | {customrequest = 'PUT', 1672 | postfields = json.encode(data), 1673 | }) 1674 | end 1675 | 1676 | function MatrixServer:ListRooms(arg) 1677 | local apipart = ('/publicRooms?access_token=%s'):format(urllib.quote(self.access_token)) 1678 | if arg then 1679 | local url = 'https://' .. arg .. "/_matrix/client/r0" 1680 | http(url..apipart) 1681 | else 1682 | http(apipart) 1683 | end 1684 | end 1685 | 1686 | function MatrixServer:Invite(room_id, user_id) 1687 | local data = { 1688 | user_id = user_id 1689 | } 1690 | http(('/rooms/%s/invite?access_token=%s') 1691 | :format(urllib.quote(room_id), 1692 | urllib.quote(self.access_token)), 1693 | {customrequest = 'POST', 1694 | postfields = json.encode(data), 1695 | }) 1696 | end 1697 | 1698 | function MatrixServer:Nick(displayname) 1699 | local data = { 1700 | displayname = displayname, 1701 | } 1702 | http(('/profile/%s/displayname?access_token=%s') 1703 | :format( 1704 | urllib.quote(self.user_id), 1705 | urllib.quote(self.access_token)), 1706 | {customrequest = 'PUT', 1707 | postfields = json.encode(data), 1708 | }) 1709 | end 1710 | 1711 | function buffer_input_cb(b, buffer, data) 1712 | for r_id, room in pairs(SERVER.rooms) do 1713 | if buffer == room.buffer then 1714 | data = data:gsub('^//', '/') 1715 | SERVER:Msg(r_id, data) 1716 | break 1717 | end 1718 | end 1719 | return w.WEECHAT_RC_OK 1720 | end 1721 | 1722 | Room = {} 1723 | Room.__index = Room 1724 | Room.create = function(obj) 1725 | local room = {} 1726 | setmetatable(room, Room) 1727 | room.buffer = nil 1728 | room.identifier = obj['room_id'] 1729 | local _, server = room.identifier:match('^(.*):(.+)$') 1730 | room.server = server 1731 | room.member_count = 0 1732 | -- Cache users for presence/nicklist 1733 | room.users = {} 1734 | -- Table of ids currently typing 1735 | room.typing_ids = {} 1736 | -- Cache the rooms power levels state 1737 | room.power_levels = {users={}, users_default=0} 1738 | -- Encryption status of room 1739 | room.encrypted = false 1740 | room.visibility = 'public' 1741 | room.join_rule = nil 1742 | room.roomname = nil -- m.room.name 1743 | room.aliases = nil -- aliases 1744 | room.canonical_alias = nil 1745 | 1746 | -- We might not be a member yet 1747 | local state_events = obj.state or {} 1748 | for _, state in ipairs(state_events) do 1749 | if state['type'] == 'm.room.aliases' then 1750 | local name = state.content.aliases[1] 1751 | if name then 1752 | room.name, _ = name:match('(.+):(.+)') 1753 | end 1754 | end 1755 | end 1756 | if not room.name then 1757 | room.name = room.identifier 1758 | end 1759 | if not room.server then 1760 | room.server = 'matrix' 1761 | end 1762 | 1763 | room.visibility = obj.visibility 1764 | if not obj['visibility'] then 1765 | room.visibility = 'public' 1766 | end 1767 | 1768 | return room 1769 | end 1770 | 1771 | function Room:SetName(name) 1772 | if not name or name == '' or name == json.null then 1773 | return 1774 | end 1775 | -- override hierarchy 1776 | if self.roomname and self.roomname ~= '' then 1777 | name = self.roomname 1778 | elseif self.canonical_alias then 1779 | name = self.canonical_alias 1780 | local short_name, _ = self.canonical_alias:match('^(.-):(.+)$') 1781 | if short_name then 1782 | name = short_name 1783 | end 1784 | elseif self.aliases then 1785 | local alias = self.aliases[1] 1786 | if name and alias then 1787 | local _ 1788 | name, _ = alias:match('(.+):(.+)') 1789 | end 1790 | else 1791 | -- NO names. Set dynamic name based on members 1792 | local new = {} 1793 | for id, nick in pairs(self.users) do 1794 | -- Set the name to the other party 1795 | if id ~= SERVER.user_id then 1796 | new[#new+1] = nick 1797 | end 1798 | end 1799 | name = table.concat(new, ',') 1800 | end 1801 | 1802 | if not name or name == '' or name == json.null then 1803 | return 1804 | end 1805 | 1806 | -- Replace spaces with _, since weechat has poor support for names with 1807 | -- spaces. 1808 | -- (see weechat/weechat#937 https://github.com/weechat/weechat/issues/937) 1809 | -- name = name:gsub(" ", "_") 1810 | 1811 | -- Check for dupe 1812 | local buffer_name = w.buffer_get_string(self.buffer, 'name') 1813 | if buffer_name == name then 1814 | return 1815 | end 1816 | 1817 | 1818 | w.buffer_set(self.buffer, "short_name", name) 1819 | w.buffer_set(self.buffer, "name", name) 1820 | -- Doesn't work 1821 | w.buffer_set(self.buffer, "plugin", "matrix") 1822 | w.buffer_set(self.buffer, "full_name", 1823 | self.server.."."..name) 1824 | w.buffer_set(self.buffer, "localvar_set_channel", name) 1825 | end 1826 | 1827 | function Room:Topic(topic) 1828 | SERVER:state(self.identifier, 'm.room.topic', {topic=topic}) 1829 | end 1830 | 1831 | function Room:Name(name) 1832 | SERVER:state(self.identifier, 'm.room.name', {name=name}) 1833 | end 1834 | 1835 | function Room:public() 1836 | SERVER:state(self.identifier, 'm.room.join_rules', {join_rule='public'}) 1837 | end 1838 | 1839 | function Room:Upload(filename) 1840 | SERVER:Upload(self.identifier, filename) 1841 | end 1842 | 1843 | function Room:Msg(msg) 1844 | SERVER:Msg(self.identifier, msg) 1845 | end 1846 | 1847 | function Room:emote(msg) 1848 | SERVER:emote(self.identifier, msg) 1849 | end 1850 | 1851 | function Room:Notice(msg) 1852 | SERVER:notice(self.identifier, msg) 1853 | end 1854 | 1855 | function Room:SendTypingNotice() 1856 | SERVER:SendTypingNotice(self.identifier) 1857 | end 1858 | 1859 | function Room:create_buffer() 1860 | --local buffer = w.buffer_search("", ("%s.%s"):format(self.server, self.name)) 1861 | self.buffer = w.buffer_new(("%s.%s") 1862 | :format(self.server, self.name), "buffer_input_cb", 1863 | self.name, "closed_matrix_room_cb", "") 1864 | -- Needs to correspond with return values from Room:GetNickGroup() 1865 | -- We will use 5 nick groups: 1866 | -- 1: Ops 1867 | -- 2: Half-ops 1868 | -- 3: Voice 1869 | -- 4: People with presence 1870 | -- 5: People without presence 1871 | self.nicklist_groups = { 1872 | -- Emulate OPs 1873 | w.nicklist_add_group(self.buffer, 1874 | '', "000|o", "weechat.color.nicklist_group", 1), 1875 | w.nicklist_add_group(self.buffer, 1876 | '', "001|h", "weechat.color.nicklist_group", 1), 1877 | -- Emulate half-op 1878 | w.nicklist_add_group(self.buffer, 1879 | '', "002|v", "weechat.color.nicklist_group", 1), 1880 | -- Defined in weechat's irc-nick.h 1881 | w.nicklist_add_group(self.buffer, 1882 | '', "998|...", "weechat.color.nicklist_group", 1), 1883 | w.nicklist_add_group(self.buffer, 1884 | '', "999|...", "weechat.color.nicklist_group", 1), 1885 | } 1886 | w.buffer_set(self.buffer, "nicklist", "1") 1887 | -- Set to 1 for easier debugging of nick groups 1888 | w.buffer_set(self.buffer, "nicklist_display_groups", "0") 1889 | w.buffer_set(self.buffer, "localvar_set_server", self.server) 1890 | w.buffer_set(self.buffer, "localvar_set_roomid", self.identifier) 1891 | self:SetName(self.name) 1892 | if self.membership == 'invite' then 1893 | self:addNick(self.inviter) 1894 | if w.config_get_plugin('autojoin_on_invite') ~= 'on' then 1895 | w.print_date_tags( 1896 | self.buffer, 1897 | nil, 1898 | 'notify_message', 1899 | ('You have been invited to join room %s by %s. Type /join in this buffer to join.') 1900 | :format( 1901 | self.name, 1902 | self.inviter, 1903 | self.identifier) 1904 | ) 1905 | end 1906 | end 1907 | end 1908 | 1909 | function Room:Freeze() 1910 | -- Function that saves all the lines in a buffer in a cache to be thawed 1911 | -- later. Used to redraw buffer when user requests more lines. Since 1912 | -- WeeChat can only render lines in order this is the workaround 1913 | local freezer = {} 1914 | local lines = w.hdata_pointer(w.hdata_get('buffer'), self.buffer, 'own_lines') 1915 | if lines == '' then return end 1916 | -- Start at top 1917 | local line = w.hdata_pointer(w.hdata_get('lines'), lines, 'first_line') 1918 | if line == '' then return end 1919 | local hdata_line = w.hdata_get('line') 1920 | local hdata_line_data = w.hdata_get('line_data') 1921 | while #line > 0 do 1922 | local data = w.hdata_pointer(hdata_line, line, 'data') 1923 | local tags = {} 1924 | local tag_count = w.hdata_integer(hdata_line_data, data, "tags_count") 1925 | if tag_count > 0 then 1926 | for i = 0, tag_count-1 do 1927 | local tag = w.hdata_string(hdata_line_data, data, i .. "|tags_array") 1928 | -- Skip notify tags since this is backlog 1929 | if not tag:match'^notify' then 1930 | tags[#tags+1] = tag 1931 | end 1932 | end 1933 | end 1934 | tags[#tags+1] = 'no_log' 1935 | freezer[#freezer+1] = { 1936 | time = w.hdata_integer(hdata_line_data, data, 'time'), 1937 | tags = tags, 1938 | prefix = w.hdata_string(hdata_line_data, data, 'prefix'), 1939 | message = w.hdata_string(hdata_line_data, data, 'message'), 1940 | } 1941 | -- Move forward since we start at top 1942 | line = w.hdata_move(hdata_line, line, 1) 1943 | end 1944 | self.freezer = freezer 1945 | end 1946 | 1947 | function Room:Thaw() 1948 | for _,l in ipairs(self.freezer) do 1949 | w.print_date_tags( 1950 | self.buffer, 1951 | l.time, 1952 | table.concat(l.tags, ','), 1953 | l.prefix .. '\t' .. l.message 1954 | ) 1955 | end 1956 | -- Clear old data 1957 | self.freezer = nil 1958 | end 1959 | 1960 | function Room:Clear() 1961 | w.buffer_clear(self.buffer) 1962 | end 1963 | 1964 | function Room:destroy() 1965 | w.buffer_close(self.buffer) 1966 | end 1967 | 1968 | function Room:_nickListChanged() 1969 | -- Check the user count, if it's 2 or less then we decide this buffer 1970 | -- is a "private" one like IRC's query type 1971 | if self.member_count == 3 then 1972 | w.buffer_set(self.buffer, "localvar_set_type", 'channel') 1973 | self.buffer_type = 'channel' 1974 | elseif self.member_count == 2 then 1975 | -- At the point where we reach two nicks, set the buffer name to be 1976 | -- the display name of the other guy that is not our self since it's 1977 | -- in effect a query, but the matrix protocol doesn't have such 1978 | -- a concept 1979 | w.buffer_set(self.buffer, "localvar_set_type", 'private') 1980 | self.buffer_type = 'query' 1981 | elseif self.member_count == 1 then 1982 | if not self.roomname and not self.aliases then 1983 | -- Set the name to ourselves 1984 | self:SetName(self.users[SERVER.user_id]) 1985 | end 1986 | end 1987 | end 1988 | 1989 | function Room:addNick(user_id, displayname) 1990 | local newnick = false 1991 | -- Sanitize displaynames a bit 1992 | if not displayname 1993 | or displayname == json.null 1994 | or displayname == '' 1995 | or w.config_get_plugin('nick_style') == 'uid' 1996 | or displayname:match'^%s+$' then 1997 | displayname = user_id:match('@(.*):.+') 1998 | end 1999 | if not self.users[user_id] then 2000 | self.member_count = self.member_count + 1 2001 | newnick = true 2002 | end 2003 | 2004 | if self.users[user_id] ~= displayname then 2005 | self.users[user_id] = displayname 2006 | end 2007 | 2008 | local nick_c = self:GetPresenceNickColor(user_id, SERVER.presence[user_id]) 2009 | -- Check if this is ourselves 2010 | if user_id == SERVER.user_id then 2011 | w.buffer_set(self.buffer, "highlight_words", displayname) 2012 | w.buffer_set(self.buffer, "localvar_set_nick", displayname) 2013 | end 2014 | 2015 | local ngroup, nprefix, nprefix_color = self:GetNickGroup(user_id) 2016 | -- Check if nick already exists 2017 | --local nick_ptr = w.nicklist_search_nick(self.buffer, '', displayname) 2018 | --if nick_ptr == '' then 2019 | local nick_ptr = w.nicklist_add_nick(self.buffer, 2020 | self.nicklist_groups[ngroup], 2021 | displayname, 2022 | nick_c, nprefix, nprefix_color, 1) 2023 | --else 2024 | -- -- TODO CHANGE nickname here 2025 | --end 2026 | if nick_ptr == '' then 2027 | -- Duplicate nick names :( 2028 | -- We just add the full id to the nicklist so atleast it will show 2029 | -- but we should probably assign something new and track the state 2030 | -- so we can print msgs with non-conflicting nicks too 2031 | w.nicklist_add_nick(self.buffer, 2032 | self.nicklist_groups[ngroup], 2033 | user_id, 2034 | nick_c, nprefix, nprefix_color, 1) 2035 | -- Since we can't allow duplicate displaynames, we just use the 2036 | -- user_id straight up. Maybe we could invent some clever 2037 | -- scheme here, like user(homeserver), user (2) or something 2038 | self.users[user_id] = user_id 2039 | end 2040 | 2041 | if newnick then -- run this after nick been added so it can be used 2042 | self:_nickListChanged() 2043 | end 2044 | 2045 | return displayname 2046 | end 2047 | 2048 | function Room:GetNickGroup(user_id) 2049 | -- TODO, cache 2050 | local ngroup = 5 2051 | local nprefix = ' ' 2052 | local nprefix_color = '' 2053 | if self:GetPowerLevel(user_id) >= 100 then 2054 | ngroup = 1 2055 | nprefix = '&' 2056 | nprefix_color = 'lightgreen' 2057 | if user_id == self.creator then 2058 | nprefix = '~' 2059 | nprefix_color = 'lightred' 2060 | end 2061 | elseif self:GetPowerLevel(user_id) >= 50 then 2062 | ngroup = 2 2063 | nprefix = '@' 2064 | nprefix_color = 'lightgreen' 2065 | elseif self:GetPowerLevel(user_id) > 0 then 2066 | ngroup = 3 2067 | nprefix = '+' 2068 | nprefix_color = 'yellow' 2069 | elseif SERVER.presence[user_id] then 2070 | -- User has a presence, put him in group3 2071 | ngroup = 4 2072 | end 2073 | return ngroup, nprefix, nprefix_color 2074 | end 2075 | 2076 | function Room:GetPowerLevel(user_id) 2077 | return tonumber(self.power_levels.users[user_id] or self.power_levels.users_default or 0) 2078 | end 2079 | 2080 | function Room:ClearTyping() 2081 | for user_id, nick in pairs(self.users) do 2082 | local _, nprefix, nprefix_color = self:GetNickGroup(user_id) 2083 | self:UpdateNick(user_id, 'prefix', nprefix) 2084 | self:UpdateNick(user_id, 'prefix_color', nprefix_color) 2085 | end 2086 | end 2087 | 2088 | function Room:GetPresenceNickColor(user_id, presence) 2089 | local nick = self.users[user_id] 2090 | local nick_c 2091 | if user_id == SERVER.user_id then 2092 | -- Always use correct color for self 2093 | nick_c = 'weechat.color.chat_nick_self' 2094 | elseif presence == 'online' then 2095 | nick_c = w.info_get('irc_nick_color_name', nick) 2096 | elseif presence == 'unavailable' then 2097 | nick_c = 'weechat.color.nicklist_away' 2098 | elseif presence == 'offline' then 2099 | nick_c = 'red' 2100 | elseif presence == nil then 2101 | nick_c = 'bar_fg' 2102 | else 2103 | dbg{err='unknown presence type',presence=presence} 2104 | end 2105 | return nick_c 2106 | end 2107 | 2108 | function Room:UpdatePresence(user_id, presence) 2109 | if presence == 'typing' then 2110 | self:UpdateNick(user_id, 'prefix', '!') 2111 | self:UpdateNick(user_id, 'prefix_color', 'magenta') 2112 | return 2113 | end 2114 | local nick_c = self:GetPresenceNickColor(user_id, presence) 2115 | self:UpdateNick(user_id, 'color', nick_c) 2116 | end 2117 | 2118 | function Room:UpdateNick(user_id, key, val) 2119 | local nick = self.users[user_id] 2120 | if not nick then return end 2121 | local nick_ptr = w.nicklist_search_nick(self.buffer, '', nick) 2122 | 2123 | if nick_ptr ~= '' and key and val then 2124 | -- Check if we need to move the nick into another group 2125 | local group_ptr = w.nicklist_nick_get_pointer(self.buffer, nick_ptr, 2126 | 'group') 2127 | local ngroup, nprefix, nprefix_color = self:GetNickGroup(user_id) 2128 | if group_ptr ~= self.nicklist_groups[ngroup] then 2129 | local nick_c = w.nicklist_nick_get_string(self.buffer, nick_ptr, 2130 | 'color') 2131 | -- No WeeChat API for changing a nick's group so we will have to 2132 | -- delete the nick from the old nicklist and add it to the correct 2133 | -- nicklist group 2134 | w.nicklist_remove_nick(self.buffer, nick_ptr) 2135 | -- TODO please check if this call fails, if it does it means the 2136 | -- WeeChat version is old and has a bug so it can't remove nicks 2137 | -- and so it needs some workaround 2138 | nick_ptr = w.nicklist_add_nick(self.buffer, 2139 | self.nicklist_groups[ngroup], 2140 | nick, 2141 | nick_c, nprefix, nprefix_color, 1) 2142 | end 2143 | -- Check if we are clearing a typing notice, and don't issue updates 2144 | -- if we are, because it spams the API so much, including potential 2145 | -- relay clients 2146 | if key == 'prefix' and val == ' ' then 2147 | -- TODO check existing values like + and @ too 2148 | local prefix = w.nicklist_nick_get_string(self.buffer, nick_ptr, 2149 | key) 2150 | if prefix == '!' then 2151 | w.nicklist_nick_set(self.buffer, nick_ptr, key, val) 2152 | end 2153 | elseif key == 'prefix_color' then 2154 | local prefix_color = w.nicklist_nick_get_string(self.buffer, 2155 | nick_ptr, key) 2156 | if prefix_color ~= val then 2157 | w.nicklist_nick_set(self.buffer, nick_ptr, key, val) 2158 | end 2159 | else 2160 | -- Check if we are actually updating something, so there's less 2161 | -- updates issued (I think WeeChat sends all changes as nicklist 2162 | -- diffs to both UI code and to relay clients 2163 | local existing = w.nicklist_nick_get_string(self.buffer, nick_ptr, key) 2164 | if val ~= existing then 2165 | w.nicklist_nick_set(self.buffer, nick_ptr, key, val) 2166 | end 2167 | end 2168 | end 2169 | end 2170 | 2171 | function Room:delNick(id) 2172 | if self.users[id] then 2173 | local nick = self.users[id] 2174 | local nick_ptr = w.nicklist_search_nick(self.buffer, '', nick) 2175 | if nick_ptr ~= '' then 2176 | w.nicklist_remove_nick(self.buffer, nick_ptr) 2177 | end 2178 | self.users[id] = nil 2179 | self.member_count = self.member_count - 1 2180 | self:_nickListChanged() 2181 | return true 2182 | end 2183 | end 2184 | 2185 | function Room:UpdateLine(id, message) 2186 | if not id then return end 2187 | local lines = w.hdata_pointer(w.hdata_get('buffer'), self.buffer, 'own_lines') 2188 | if lines == '' then return end 2189 | local line = w.hdata_pointer(w.hdata_get('lines'), lines, 'last_line') 2190 | if line == '' then return end 2191 | local hdata_line = w.hdata_get('line') 2192 | local hdata_line_data = w.hdata_get('line_data') 2193 | while #line > 0 do 2194 | local needsupdate = false 2195 | local data = w.hdata_pointer(hdata_line, line, 'data') 2196 | local tags = {} 2197 | local tag_count = w.hdata_integer(hdata_line_data, data, "tags_count") 2198 | if tag_count > 0 then 2199 | for i = 0, tag_count-1 do 2200 | local tag = w.hdata_string(hdata_line_data, data, i .. "|tags_array") 2201 | tags[#tags+1] = tag 2202 | if tag:match(id) then 2203 | needsupdate = true 2204 | end 2205 | end 2206 | if needsupdate then 2207 | w.hdata_update(hdata_line_data, data, { 2208 | prefix = nil, 2209 | message = message, 2210 | tags_array = table.concat(tags, ','), 2211 | }) 2212 | return true 2213 | end 2214 | end 2215 | line = w.hdata_move(hdata_line, line, -1) 2216 | end 2217 | return false 2218 | end 2219 | 2220 | function Room:formatNick(user_id) 2221 | -- Turns a nick name into a weechat-styled nickname. This means giving 2222 | -- it colors, and proper prefix and suffix 2223 | local nick = self.users[user_id] 2224 | if not nick then 2225 | return user_id 2226 | end 2227 | -- Remove nasty white space 2228 | nick = nick:gsub('[\n\t]', '') 2229 | local color 2230 | if user_id == SERVER.user_id then 2231 | color = w.color('chat_nick_self') 2232 | else 2233 | color = w.info_get('irc_nick_color', nick) 2234 | end 2235 | local _, nprefix, nprefix_c = self:GetNickGroup(user_id) 2236 | local prefix = wconf('weechat.look.nick_prefix') 2237 | local prefix_c = wcolor('weechat.color.chat_nick_prefix') 2238 | local suffix = wconf('weechat.look.nick_suffix') 2239 | local suffix_c = wcolor('weechat.color.chat_nick_suffix') 2240 | local nick_f = prefix_c 2241 | .. prefix 2242 | .. wcolor(nprefix_c) 2243 | .. nprefix 2244 | .. color 2245 | .. nick 2246 | .. suffix_c 2247 | .. suffix 2248 | return nick_f 2249 | end 2250 | 2251 | function Room:decryptChunk(chunk) 2252 | -- vector client doesn't provide this 2253 | chunk.content.msgtype = 'm.text' 2254 | 2255 | if not olmstatus then 2256 | chunk.content.body = 'encrypted message, unable to decrypt' 2257 | return chunk 2258 | end 2259 | 2260 | chunk.content.body = 'encrypted message, unable to decrypt' 2261 | local device_key = chunk.content.sender_key 2262 | -- Find our id 2263 | local ciphertexts = chunk.content.ciphertext 2264 | local ciphertext 2265 | if not ciphertexts then 2266 | chunk.content.body = 'Recieved an encrypted message, but could not find ciphertext array' 2267 | else 2268 | ciphertext = ciphertexts[SERVER.olm.device_key] 2269 | end 2270 | if not ciphertext then 2271 | chunk.content.body = 'Recieved an encrypted message, but could not find cipher for ourselves from the sender.' 2272 | return chunk 2273 | end 2274 | 2275 | local session 2276 | local decrypted 2277 | local err 2278 | local found_session = false 2279 | local sessions = SERVER.olm:get_sessions(device_key) 2280 | for id, pickle in pairs(sessions) do 2281 | -- Check if we already successfully decrypted with a sesssion, if that 2282 | -- is the case we break the loop 2283 | if decrypted then 2284 | break 2285 | end 2286 | session = olm.Session.new() 2287 | session:unpickle(OLM_KEY, pickle) 2288 | local matches_inbound = session:matches_inbound(ciphertext.body) 2289 | ---if ciphertext.type == 0 and matches_inbound then 2290 | if matches_inbound then 2291 | found_session = true 2292 | end 2293 | local cleartext 2294 | cleartext, err = session:decrypt(ciphertext.type, ciphertext.body) 2295 | if not err then 2296 | if DEBUG then 2297 | perr(('olm: Able to decrypt with an existing session %s'):format(session:session_id())) 2298 | end 2299 | decrypted = cleartext 2300 | SERVER.olm:store_session(device_key, session) 2301 | else 2302 | chunk.content.body = "Decryption error: "..err 2303 | if DEBUG then 2304 | perr(('olm: Unable to decrypt with an existing session: %s. Session-ID: %s'):format(err, session:session_id())) 2305 | end 2306 | end 2307 | session:clear() 2308 | end 2309 | if ciphertext.type == 0 and not found_session and not decrypted then 2310 | session = olm.Session.new() 2311 | local _ 2312 | _, err = session:create_inbound_from( 2313 | SERVER.olm.account, device_key, ciphertext.body) 2314 | if err then 2315 | session:clear() 2316 | chunk.content.body = "Decryption error: create inbound "..err 2317 | return chunk 2318 | end 2319 | decrypted, err = session:decrypt(ciphertext.type, ciphertext.body) 2320 | if err then 2321 | session:clear() 2322 | chunk.content.body = "Decryption error: "..err 2323 | return chunk 2324 | end 2325 | -- TODO SERVER.olm.account:remove_one_time_keys(session) 2326 | local session_id = session:session_id() 2327 | perr(('Session ID: %s, user_id: %s, device_id: %s'): 2328 | format(session_id, SERVER.user_id, SERVER.olm.device_id)) 2329 | SERVER.olm:store_session(device_key, session) 2330 | session:clear() 2331 | if err then 2332 | chunk.content.body = "Decryption error: "..err 2333 | return chunk 2334 | end 2335 | end 2336 | 2337 | if decrypted then 2338 | local success, payload = pcall(json.decode, decrypted) 2339 | if not success then 2340 | chunk.content.body = "Payload error: "..payload 2341 | return chunk 2342 | end 2343 | -- TODO use the room id from payload for security 2344 | chunk.content.msgtype = payload.content.msgtype 2345 | -- Style the message so user can tell if it's 2346 | -- an encrypted message or not 2347 | local color = w.color(w.config_get_plugin( 2348 | 'encrypted_message_color')) 2349 | chunk.content.body = color .. payload.content.body 2350 | end 2351 | 2352 | return chunk 2353 | end 2354 | 2355 | -- Parses a chunk of json meant for a room 2356 | function Room:ParseChunk(chunk, backlog, chunktype) 2357 | local taglist = {} 2358 | local tag = function(tag) 2359 | -- Helper function to add tags 2360 | if type(tag) == 'table' then 2361 | for _, t in ipairs(tag) do 2362 | taglist[t] = true 2363 | end 2364 | else 2365 | taglist[tag] = true 2366 | end 2367 | end 2368 | local tags = function() 2369 | -- Helper for returning taglist for this message 2370 | local out = {} 2371 | for k, v in pairs(taglist) do 2372 | table.insert(out, k) 2373 | end 2374 | return table.concat(out, ',') 2375 | end 2376 | if not backlog then 2377 | backlog = false 2378 | end 2379 | 2380 | if backlog then 2381 | tag{'no_highlight','notify_none','no_log'} 2382 | end 2383 | 2384 | local is_self = false 2385 | local was_decrypted = false 2386 | 2387 | -- Sender of chunk, used to be chunk.user_id, v2 uses chunk.sender 2388 | local sender = chunk.sender or chunk.user_id 2389 | -- Check if own message 2390 | if sender == SERVER.user_id then 2391 | is_self = true 2392 | tag{'no_highlight','notify_none'} 2393 | end 2394 | -- Add Event ID to each line so can use it later to match on for things 2395 | -- like redactions and localecho, etc 2396 | tag{chunk.event_id} 2397 | 2398 | -- Some messages are missing ts 2399 | local origin_server_ts = chunk['origin_server_ts'] or 0 2400 | local time_int = origin_server_ts/1000 2401 | 2402 | if chunk['type'] == 'm.room.message' or chunk['type'] == 'm.room.encrypted' then 2403 | if chunk['type'] == 'm.room.encrypted' then 2404 | tag{'no_log'} -- Don't log encrypted message 2405 | chunk = self:decryptChunk(chunk) 2406 | was_decrypted = true 2407 | end 2408 | 2409 | if not backlog and not is_self then 2410 | tag'notify_message' 2411 | if self.buffer_type == 'query' then 2412 | tag'notify_private' 2413 | end 2414 | end 2415 | 2416 | local color = default_color 2417 | local content = chunk['content'] 2418 | local body = content['body'] 2419 | 2420 | if not content['msgtype'] then 2421 | -- We don't support redactions 2422 | return 2423 | end 2424 | 2425 | -- If it has transaction id, it is from this client. 2426 | local is_from_this_client = false 2427 | if chunk.unsigned and chunk.unsigned.transaction_id then 2428 | is_from_this_client = true 2429 | end 2430 | 2431 | -- luacheck: ignore 542 2432 | if content['msgtype'] == 'm.text' then 2433 | -- TODO 2434 | -- Parse HTML here: 2435 | -- content.format = 'org.matrix.custom.html' 2436 | -- fontent.formatted_body... 2437 | elseif content['msgtype'] == 'm.image' then 2438 | local url = content['url'] 2439 | if type(url) ~= 'string' then 2440 | url = '' 2441 | end 2442 | url = url:gsub('mxc://', 2443 | w.config_get_plugin('homeserver_url') 2444 | .. '_matrix/media/v1/download/') 2445 | -- Synapse homeserver supports arbitrary file endings, so we put 2446 | -- filename at the end to make it nicer for URL "sniffers" to 2447 | -- realise it's a image URL 2448 | body = url .. '/' .. content.body 2449 | elseif content.msgtype == 'm.file' or content.msgtype == 'm.video' or 2450 | content.msgtype == 'm.audio' then 2451 | local url = content['url'] or '' 2452 | url = url:gsub('mxc://', 2453 | w.config_get_plugin('homeserver_url') 2454 | .. '_matrix/media/v1/download/') 2455 | body = 'File upload: ' .. 2456 | tostring(content['body']) 2457 | .. ' ' .. url 2458 | elseif content['msgtype'] == 'm.notice' then 2459 | color = wcolor('irc.color.notice') 2460 | body = content['body'] 2461 | elseif content['msgtype'] == 'm.emote' then 2462 | local nick_c 2463 | local nick = self.users[sender] or sender 2464 | if is_self then 2465 | nick_c = w.color('chat_nick_self') 2466 | else 2467 | nick_c = w.info_get('irc_nick_color', nick) 2468 | end 2469 | tag"irc_action" 2470 | local prefix_c = wcolor'weechat.color.chat_prefix_action' 2471 | local prefix = wconf'weechat.look.prefix_action' 2472 | body = ("%s%s %s%s"):format( 2473 | nick_c, nick, color, content['body'] 2474 | ) 2475 | prefix = prefix_c .. prefix 2476 | local data = ("%s\t%s"):format(prefix, body) 2477 | if not backlog and is_self and is_from_this_client and 2478 | ( w.config_get_plugin('local_echo') == 'on' 2479 | or was_decrypted -- local echo for encryption 2480 | ) 2481 | then 2482 | -- We have already locally echoed this line 2483 | return 2484 | else 2485 | return w.print_date_tags(self.buffer, time_int, tags(), data) 2486 | end 2487 | else 2488 | -- Unknown content type, but if it contains an URL we will print 2489 | -- URL and body 2490 | local url = content['url'] 2491 | if url ~= nil then 2492 | url = url:gsub('mxc://', 2493 | w.config_get_plugin('homeserver_url') 2494 | .. '_matrix/media/v1/download/') 2495 | body = content['body'] .. ' ' .. url 2496 | end 2497 | dbg { 2498 | warning='Warning: unknown/unhandled content type', 2499 | event=content 2500 | } 2501 | end 2502 | if not backlog and is_self and is_from_this_client 2503 | -- TODO better check, to work for multiple weechat clients 2504 | and ( 2505 | w.config_get_plugin('local_echo') == 'on' 2506 | or was_decrypted -- local echo for encrypted messages 2507 | ) 2508 | and (-- we don't generate local echo for files and images 2509 | content.msgtype == 'm.text' 2510 | ) 2511 | then 2512 | -- We have already locally echoed this line 2513 | return 2514 | end 2515 | local data = ("%s\t%s%s"):format( 2516 | self:formatNick(sender), 2517 | color, 2518 | body) 2519 | w.print_date_tags(self.buffer, time_int, tags(), data) 2520 | elseif chunk['type'] == 'm.room.topic' then 2521 | local title = chunk['content']['topic'] 2522 | if not title then 2523 | title = '' 2524 | end 2525 | w.buffer_set(self.buffer, "title", title) 2526 | local color = wcolor("irc.color.topic_new") 2527 | local nick = self.users[sender] or sender 2528 | local data = ('--\t%s%s has changed the topic to "%s%s%s"'):format( 2529 | nick, 2530 | default_color, 2531 | color, 2532 | title, 2533 | default_color 2534 | ) 2535 | w.print_date_tags(self.buffer, chunk.origin_server_ts, tags(), 2536 | data) 2537 | elseif chunk['type'] == 'm.room.name' then 2538 | local name = chunk['content']['name'] 2539 | if name ~= '' or name ~= json.null then 2540 | self.roomname = name 2541 | self:SetName(name) 2542 | end 2543 | elseif chunk['type'] == 'm.room.member' then 2544 | if chunk['content']['membership'] == 'join' then 2545 | tag"irc_join" 2546 | --- FIXME shouldn't be neccessary adding all the time 2547 | local name = self.users[sender] or self:addNick(sender, chunk.content.displayname) 2548 | if not name or name == json.null or name == '' then 2549 | name = sender 2550 | end 2551 | SERVER.avatars[name] = chunk.content.avatar_url 2552 | -- Check if the chunk has prev_content or not 2553 | -- if there is prev_content there wasn't a join but a nick change 2554 | -- or duplicate join 2555 | local prev_content = chunk.unsigned and chunk.unsigned.prev_content 2556 | if prev_content 2557 | and prev_content.membership == 'join' 2558 | and chunktype == 'messages' then 2559 | local nick = chunk.content.displayname or sender 2560 | if not nick or nick == json.null or nick == '' then 2561 | nick = sender 2562 | end 2563 | local oldnick = prev_content.displayname 2564 | if not oldnick or oldnick == json.null then 2565 | oldnick = sender 2566 | end 2567 | 2568 | if oldnick == nick then 2569 | -- Maybe they changed their avatar or something else 2570 | -- that we don't care about (or multiple joins) 2571 | return 2572 | end 2573 | 2574 | self:delNick(sender) 2575 | self:addNick(sender, nick) 2576 | 2577 | local pcolor = wcolor'weechat.color.chat_prefix_network' 2578 | tag'irc_nick' 2579 | local data = ('%s--\t%s%s%s is now known as %s%s'):format( 2580 | pcolor, 2581 | w.info_get('irc_nick_color', oldnick), 2582 | oldnick, 2583 | default_color, 2584 | w.info_get('irc_nick_color', name), 2585 | nick) 2586 | w.print_date_tags(self.buffer, time_int, tags(), data) 2587 | elseif chunktype == 'messages' then 2588 | tag"irc_smart_filter" 2589 | local data = ('%s%s\t%s%s%s (%s%s%s) joined the room.'):format( 2590 | wcolor('weechat.color.chat_prefix_join'), 2591 | wconf('weechat.look.prefix_join'), 2592 | w.info_get('irc_nick_color', name), 2593 | name, 2594 | wcolor('irc.color.message_join'), 2595 | wcolor'weechat.color.chat_host', 2596 | sender, 2597 | wcolor('irc.color.message_join') 2598 | ) 2599 | w.print_date_tags(self.buffer, time_int, tags(), data) 2600 | -- if this is an encrypted room, also download key 2601 | if olmstatus and self.encrypted then 2602 | SERVER.olm:query{sender} 2603 | end 2604 | end 2605 | elseif chunk['content']['membership'] == 'leave' then 2606 | if chunktype == 'messages' then 2607 | local nick = self.users[chunk.state_key] or sender 2608 | local prev = chunk.unsigned.prev_content 2609 | if (prev and 2610 | prev.displayname and 2611 | prev.displayname ~= json.null) then 2612 | nick = prev.displayname 2613 | end 2614 | if sender ~= chunk.state_key then -- Kick 2615 | tag{"irc_quit","irc_kick","irc_smart_filter"} 2616 | local reason = chunk.content.reason or '' 2617 | local sender_nick = self.users[chunk.sender] 2618 | local data = ('%s%s\t%s%s%s has kicked %s%s%s (%s).'):format( 2619 | wcolor('weechat.color.chat_prefix_quit'), 2620 | wconf('weechat.look.prefix_quit'), 2621 | w.info_get('irc_nick_color', sender_nick), 2622 | sender_nick, 2623 | wcolor('irc.color.message_quit'), 2624 | w.info_get('irc_nick_color', nick), 2625 | nick, 2626 | default_color, 2627 | reason 2628 | ) 2629 | w.print_date_tags(self.buffer, time_int, tags(), data) 2630 | else 2631 | tag{"irc_quit","irc_smart_filter"} 2632 | local data = ('%s%s\t%s%s%s left the room.'):format( 2633 | wcolor('weechat.color.chat_prefix_quit'), 2634 | wconf('weechat.look.prefix_quit'), 2635 | w.info_get('irc_nick_color', nick), 2636 | nick, 2637 | wcolor('irc.color.message_quit') 2638 | ) 2639 | w.print_date_tags(self.buffer, time_int, tags(), data) 2640 | end 2641 | end 2642 | self:delNick(chunk.state_key) 2643 | elseif chunk['content']['membership'] == 'invite' then 2644 | -- Check if we were the one being invited 2645 | if chunk.state_key == SERVER.user_id and ( 2646 | (not backlog and chunktype == 'messages') or 2647 | chunktype == 'states') then 2648 | --self:addNick(sender) 2649 | if w.config_get_plugin('autojoin_on_invite') == 'on' then 2650 | SERVER:Join(self.identifier) 2651 | mprint(('%s invited you'):format(sender)) 2652 | else 2653 | mprint(('You have been invited to join room %s by %s. Type /join %s to join.') 2654 | :format( 2655 | self.name, 2656 | sender, 2657 | self.identifier)) 2658 | end 2659 | end 2660 | if chunktype == 'messages' then 2661 | tag"irc_invite" 2662 | local prefix_c = wcolor'weechat.color.chat_prefix_action' 2663 | local prefix = wconf'weechat.look.prefix_action' 2664 | local data = ("%s%s\t%s invited %s to join"):format( 2665 | prefix_c, 2666 | prefix, 2667 | self.users[sender] or sender, 2668 | self.users[chunk.state_key] or chunk.state_key 2669 | ) 2670 | w.print_date_tags(self.buffer, time_int, tags(), data) 2671 | end 2672 | elseif chunk['content']['membership'] == 'ban' then 2673 | if chunktype == 'messages' then 2674 | tag"irc_ban" 2675 | local prefix_c = wcolor'weechat.color.chat_prefix_action' 2676 | local prefix = wconf'weechat.look.prefix_action' 2677 | local data = ("%s%s\t%s banned %s"):format( 2678 | prefix_c, 2679 | prefix, 2680 | self.users[sender] or sender, 2681 | self.users[chunk.state_key] or chunk.state_key 2682 | ) 2683 | w.print_date_tags(self.buffer, time_int, tags(), data) 2684 | end 2685 | else 2686 | dbg{err= 'unknown membership type in ParseChunk', chunk= chunk} 2687 | end 2688 | -- if it's backlog this is done at the end from the caller place 2689 | if not backlog then 2690 | -- Run SetName on each member change in case we need to update room name 2691 | self:SetName(self.identifier) 2692 | end 2693 | elseif chunk['type'] == 'm.room.create' then 2694 | self.creator = chunk.content.creator 2695 | elseif chunk['type'] == 'm.room.power_levels' then 2696 | if chunk.content.users then 2697 | self.power_levels = chunk.content 2698 | for user_id, lvl in pairs(self.power_levels.users) do 2699 | -- TODO 2700 | -- calculate changes here and generate message lines 2701 | -- describing the change 2702 | end 2703 | for user_id, lvl in pairs(self.power_levels.users) do 2704 | local _, nprefix, nprefix_color = self:GetNickGroup(user_id) 2705 | self:UpdateNick(user_id, 'prefix', nprefix) 2706 | self:UpdateNick(user_id, 'prefix_color', nprefix_color) 2707 | end 2708 | end 2709 | elseif chunk['type'] == 'm.room.join_rules' then 2710 | -- TODO: parse join_rules events -- 2711 | self.join_rules = chunk.content 2712 | elseif chunk['type'] == 'm.typing' then 2713 | -- Store the typing ids in a table that the bar item can use 2714 | local typing_ids = {} 2715 | for _, id in ipairs(chunk.content.user_ids) do 2716 | self:UpdatePresence(id, 'typing') 2717 | typing_ids[#typing_ids+1] = self.users[id] 2718 | end 2719 | self.typing_ids = typing_ids 2720 | w.bar_item_update('matrix_typing_notice') 2721 | elseif chunk['type'] == 'm.presence' then 2722 | SERVER:UpdatePresence(chunk) 2723 | elseif chunk['type'] == 'm.room.aliases' then 2724 | -- Use first alias, weechat doesn't really support multiple aliases 2725 | self.aliases = chunk.content.aliases 2726 | self:SetName(chunk.content.aliases[1]) 2727 | elseif chunk['type'] == 'm.room.canonical_alias' then 2728 | self.canonical_alias = chunk.content.alias 2729 | self:SetName(self.canonical_alias) 2730 | elseif chunk['type'] == 'm.room.redaction' then 2731 | local redact_id = chunk.redacts 2732 | --perr('Redacting message ' .. redact_id) 2733 | local result = self:UpdateLine(redact_id, w.color'darkgray'..'(redacted)') 2734 | if not result and not backlog then 2735 | -- backlog doesn't send original message 2736 | perr(('Could not find message to redact :(. Redaction ID is: %s'):format(redact_id)) 2737 | end 2738 | elseif chunk['type'] == 'm.room.history_visibility' then 2739 | self.history_visibility = chunk.content.history_visibility 2740 | -- luacheck: ignore 542 2741 | elseif chunk['type'] == 'm.receipt' then 2742 | -- TODO: figure out if we can do something sensible with read receipts 2743 | elseif chunk['type'] == 'm.fully_read' and self.buffer ~= current_buffer then 2744 | -- we don't want to update read line for the current buffer 2745 | -- TODO: check if read marker correspond to the last event in the room 2746 | w.buffer_set(self.buffer, "unread", "") 2747 | w.buffer_set(self.buffer, "hotlist", "-1") 2748 | else 2749 | if DEBUG then 2750 | perr(('Unknown event type %s%s%s in room %s%s%s'):format( 2751 | w.color'bold', 2752 | chunk.type, 2753 | default_color, 2754 | w.color'bold', 2755 | self.name, 2756 | default_color)) 2757 | dbg{chunk=chunk} 2758 | end 2759 | end 2760 | end 2761 | 2762 | function Room:Op(nick) 2763 | for id, name in pairs(self.users) do 2764 | if name == nick then 2765 | -- patch the locally cached power levels 2766 | self.power_levels.users[id] = 99 2767 | SERVER:state(self.identifier, 'm.room.power_levels', 2768 | self.power_levels) 2769 | break 2770 | end 2771 | end 2772 | end 2773 | 2774 | function Room:Voice(nick) 2775 | for id, name in pairs(self.users) do 2776 | if name == nick then 2777 | -- patch the locally cached power levels 2778 | self.power_levels.users[id] = 25 2779 | SERVER:state(self.identifier, 'm.room.power_levels', 2780 | self.power_levels) 2781 | break 2782 | end 2783 | end 2784 | end 2785 | 2786 | function Room:Devoice(nick) 2787 | for id, name in pairs(self.users) do 2788 | if name == nick then 2789 | -- patch the locally cached power levels 2790 | self.power_levels.users[id] = 0 2791 | SERVER:state(self.identifier, 'm.room.power_levels', 2792 | self.power_levels) 2793 | break 2794 | end 2795 | end 2796 | end 2797 | 2798 | function Room:Deop(nick) 2799 | for id, name in pairs(self.users) do 2800 | if name == nick then 2801 | -- patch the locally cached power levels 2802 | self.power_levels.users[id] = 0 2803 | SERVER:state(self.identifier, 'm.room.power_levels', 2804 | self.power_levels) 2805 | break 2806 | end 2807 | end 2808 | end 2809 | 2810 | function Room:Kick(nick, reason) 2811 | for id, name in pairs(self.users) do 2812 | if name == nick then 2813 | local data = { 2814 | membership = 'leave', 2815 | reason = 'Kicked by '..SERVER.user_id 2816 | } 2817 | SERVER:set_membership(self.identifier, id, data) 2818 | break 2819 | end 2820 | end 2821 | end 2822 | 2823 | function Room:Whois(nick) 2824 | for id, name in pairs(self.users) do 2825 | if name == nick then 2826 | local pcolor = wcolor'weechat.color.chat_prefix_network' 2827 | local data = ('%s--\t%s%s%s has user id %s%s'):format( 2828 | pcolor, 2829 | w.info_get('irc_nick_color', nick), 2830 | nick, 2831 | default_color, 2832 | w.info_get('irc_nick_color', id), 2833 | id) 2834 | w.print_date_tags(self.buffer, nil, 'notify_message', data) 2835 | local pdata = ('%s--\t%s%s%s has presence %s%s'):format( 2836 | pcolor, 2837 | w.info_get('irc_nick_color', nick), 2838 | nick, 2839 | default_color, 2840 | pcolor, 2841 | SERVER.presence[id] or 'offline') 2842 | w.print_date_tags(self.buffer, nil, 'notify_message', pdata) 2843 | 2844 | local avatar_url = SERVER.avatars[nick] 2845 | if avatar_url ~= nil then 2846 | avatar_url = avatar_url:gsub('mxc://', 2847 | w.config_get_plugin('homeserver_url') 2848 | .. '_matrix/media/v1/download/') 2849 | local avatar_line = ('%s--\t%s%s%s has avatar %s'):format( 2850 | pcolor, 2851 | w.info_get('irc_nick_color', nick), 2852 | nick, 2853 | default_color, 2854 | avatar_url) 2855 | w.print_date_tags(self.buffer, nil, 'notify_message', avatar_line) 2856 | end 2857 | -- TODO support printing status_msg field in presence data here 2858 | break 2859 | end 2860 | end 2861 | end 2862 | 2863 | function Room:Invite(id) 2864 | SERVER:Invite(self.identifier, id) 2865 | end 2866 | 2867 | function Room:Encrypt() 2868 | self.encrypted = true 2869 | -- Download keys for all members 2870 | self:Download_keys() 2871 | -- Create sessions 2872 | -- Pickle. 2873 | -- Save 2874 | end 2875 | function Room:Download_keys() 2876 | for id, name in pairs(self.users) do 2877 | -- TODO enable batch downloading of keys here when synapse can handle it 2878 | SERVER.olm:query({id}) 2879 | end 2880 | end 2881 | 2882 | function Room:MarkAsRead() 2883 | -- Get event id from tag of last line in buffer 2884 | local lines = w.hdata_pointer(w.hdata_get('buffer'), self.buffer, 'own_lines') 2885 | if lines == '' then return end 2886 | local line = w.hdata_pointer(w.hdata_get('lines'), lines, 'last_line') 2887 | if line == '' then return end 2888 | local hdata_line = w.hdata_get('line') 2889 | local hdata_line_data = w.hdata_get('line_data') 2890 | local data = w.hdata_pointer(hdata_line, line, 'data') 2891 | local tag_count = w.hdata_integer(hdata_line_data, data, "tags_count") 2892 | if tag_count > 0 then 2893 | for i = 0, tag_count-1 do 2894 | local tag = w.hdata_string(hdata_line_data, data, i .. "|tags_array") 2895 | -- Event ids are like $142533663810152bfUKc:matrix.org 2896 | if tag:match'^%$.*:' then 2897 | SERVER:SendReadMarker(self.identifier, tag) 2898 | break 2899 | end 2900 | end 2901 | end 2902 | end 2903 | 2904 | function poll(a, b) 2905 | SERVER:poll() 2906 | return w.WEECHAT_RC_OK 2907 | end 2908 | 2909 | function polltimer_cb(a, b) 2910 | local now = os.time() 2911 | if (now - SERVER.polltime) > POLL_INTERVAL+10 then 2912 | -- Release the poll lock 2913 | SERVER.poll_lock = false 2914 | SERVER:poll() 2915 | end 2916 | return w.WEECHAT_RC_OK 2917 | end 2918 | 2919 | function otktimer_cb(a, b) 2920 | SERVER.olm:check_server_keycount() 2921 | return w.WEECHAT_RC_OK 2922 | end 2923 | 2924 | function cleartyping(a, b) 2925 | for id, room in pairs(SERVER.rooms) do 2926 | room:ClearTyping() 2927 | end 2928 | return w.WEECHAT_RC_OK 2929 | end 2930 | 2931 | function join_command_cb(data, current_buffer, args) 2932 | local room = SERVER:findRoom(current_buffer) 2933 | if current_buffer == BUFFER or room then 2934 | local _, alias = split_args(args) 2935 | if not alias then 2936 | -- To support running /join on a invited room without args 2937 | SERVER:Join(room.identifier) 2938 | else 2939 | SERVER:Join(alias) 2940 | end 2941 | return w.WEECHAT_RC_OK_EAT 2942 | else 2943 | return w.WEECHAT_RC_OK 2944 | end 2945 | end 2946 | 2947 | function part_command_cb(data, current_buffer, args) 2948 | local room = SERVER:findRoom(current_buffer) 2949 | if room then 2950 | SERVER:part(room) 2951 | return w.WEECHAT_RC_OK_EAT 2952 | else 2953 | return w.WEECHAT_RC_OK 2954 | end 2955 | end 2956 | 2957 | function leave_command_cb(data, current_buffer, args) 2958 | return part_command_cb(data, current_buffer, args) 2959 | end 2960 | 2961 | function me_command_cb(data, current_buffer, args) 2962 | local room = SERVER:findRoom(current_buffer) 2963 | if room then 2964 | local _, message = split_args(args) 2965 | room:emote(message or '') 2966 | return w.WEECHAT_RC_OK_EAT 2967 | else 2968 | return w.WEECHAT_RC_OK 2969 | end 2970 | end 2971 | 2972 | function topic_command_cb(data, current_buffer, args) 2973 | local room = SERVER:findRoom(current_buffer) 2974 | if room then 2975 | local _, topic = split_args(args) 2976 | room:Topic(topic) 2977 | return w.WEECHAT_RC_OK_EAT 2978 | else 2979 | return w.WEECHAT_RC_OK 2980 | end 2981 | end 2982 | 2983 | function upload_command_cb(data, current_buffer, args) 2984 | local room = SERVER:findRoom(current_buffer) 2985 | if room then 2986 | local _, upload = split_args(args) 2987 | room:Upload(upload) 2988 | return w.WEECHAT_RC_OK_EAT 2989 | else 2990 | return w.WEECHAT_RC_OK 2991 | end 2992 | end 2993 | 2994 | function query_command_cb(data, current_buffer, args) 2995 | local room = SERVER:findRoom(current_buffer) 2996 | if room then 2997 | local _, query = split_args(args) 2998 | for id, displayname in pairs(room.users) do 2999 | if displayname == query then 3000 | -- Create a new room and invite the guy 3001 | SERVER:CreateRoom(false, nil, {id}) 3002 | return w.WEECHAT_RC_OK_EAT 3003 | end 3004 | end 3005 | else 3006 | return w.WEECHAT_RC_OK 3007 | end 3008 | end 3009 | 3010 | function create_command_cb(data, current_buffer, args) 3011 | local command, arg = split_args(args) 3012 | local room = SERVER:findRoom(current_buffer) 3013 | if (room or current_buffer == BUFFER) and command == '/create' then 3014 | if arg then 3015 | -- Room names are supposed to be without # and homeserver, so 3016 | -- we try to help the user out here 3017 | local alias = arg:match'#?(.*):?' 3018 | -- Create a non-public room with argument as alias 3019 | SERVER:CreateRoom(false, alias, nil) 3020 | else 3021 | mprint 'Use /create room-name' 3022 | end 3023 | return w.WEECHAT_RC_OK_EAT 3024 | else 3025 | return w.WEECHAT_RC_OK 3026 | end 3027 | end 3028 | 3029 | function createalias_command_cb(data, current_buffer, args) 3030 | local room = SERVER:findRoom(current_buffer) 3031 | if room then 3032 | local _, alias = split_args(args) 3033 | SERVER:CreateRoomAlias(room.identifier, alias) 3034 | return w.WEECHAT_RC_OK_EAT 3035 | elseif current_buffer == BUFFER then 3036 | mprint 'Use /createalias #alias:homeserver.domain from a room' 3037 | return w.WEECHAT_RC_OK_EAT 3038 | else 3039 | return w.WEECHAT_RC_OK 3040 | end 3041 | end 3042 | 3043 | function invite_command_cb(data, current_buffer, args) 3044 | local room = SERVER:findRoom(current_buffer) 3045 | if room then 3046 | local _, invitee = split_args(args) 3047 | room:Invite(invitee) 3048 | return w.WEECHAT_RC_OK_EAT 3049 | else 3050 | return w.WEECHAT_RC_OK 3051 | end 3052 | end 3053 | 3054 | function list_command_cb(data, current_buffer, args) 3055 | local room = SERVER:findRoom(current_buffer) 3056 | if room or current_buffer == BUFFER then 3057 | local _, target = split_args(args) 3058 | SERVER:ListRooms(target) 3059 | return w.WEECHAT_RC_OK_EAT 3060 | else 3061 | return w.WEECHAT_RC_OK 3062 | end 3063 | end 3064 | 3065 | function op_command_cb(data, current_buffer, args) 3066 | local room = SERVER:findRoom(current_buffer) 3067 | if room then 3068 | local _, target = split_args(args) 3069 | room:Op(target) 3070 | return w.WEECHAT_RC_OK_EAT 3071 | else 3072 | return w.WEECHAT_RC_OK 3073 | end 3074 | end 3075 | 3076 | function voice_command_cb(data, current_buffer, args) 3077 | local room = SERVER:findRoom(current_buffer) 3078 | if room then 3079 | local _, target = split_args(args) 3080 | room:Voice(target) 3081 | return w.WEECHAT_RC_OK_EAT 3082 | else 3083 | return w.WEECHAT_RC_OK 3084 | end 3085 | end 3086 | 3087 | function devoice_command_cb(data, current_buffer, args) 3088 | local room = SERVER:findRoom(current_buffer) 3089 | if room then 3090 | local _, target = split_args(args) 3091 | room:Devoice(target) 3092 | return w.WEECHAT_RC_OK_EAT 3093 | else 3094 | return w.WEECHAT_RC_OK 3095 | end 3096 | end 3097 | function deop_command_cb(data, current_buffer, args) 3098 | local room = SERVER:findRoom(current_buffer) 3099 | if room then 3100 | local _, target = split_args(args) 3101 | room:Deop(target) 3102 | return w.WEECHAT_RC_OK_EAT 3103 | else 3104 | return w.WEECHAT_RC_OK 3105 | end 3106 | end 3107 | 3108 | function kick_command_cb(data, current_buffer, args) 3109 | local room = SERVER:findRoom(current_buffer) 3110 | if room then 3111 | local _, target = split_args(args) 3112 | room:Kick(target) 3113 | return w.WEECHAT_RC_OK_EAT 3114 | else 3115 | return w.WEECHAT_RC_OK 3116 | end 3117 | end 3118 | 3119 | function nick_command_cb(data, current_buffer, args) 3120 | local room = SERVER:findRoom(current_buffer) 3121 | if room or current_buffer == BUFFER then 3122 | local _, nick = split_args(args) 3123 | SERVER:Nick(nick) 3124 | return w.WEECHAT_RC_OK_EAT 3125 | else 3126 | return w.WEECHAT_RC_OK 3127 | end 3128 | end 3129 | 3130 | function whois_command_cb(data, current_buffer, args) 3131 | local room = SERVER:findRoom(current_buffer) 3132 | if room then 3133 | local _, nick = split_args(args) 3134 | room:Whois(nick) 3135 | return w.WEECHAT_RC_OK_EAT 3136 | else 3137 | return w.WEECHAT_RC_OK 3138 | end 3139 | end 3140 | 3141 | function notice_command_cb(data, current_buffer, args) 3142 | -- TODO sending from matrix buffer given a room name 3143 | local room = SERVER:findRoom(current_buffer) 3144 | if room then 3145 | local _, msg = split_args(args) 3146 | room:Notice(msg) 3147 | return w.WEECHAT_RC_OK_EAT 3148 | else 3149 | return w.WEECHAT_RC_OK 3150 | end 3151 | end 3152 | 3153 | function msg_command_cb(data, current_buffer, args) 3154 | local _, msgmask = split_args(args) 3155 | local mask, msg = split_args(msgmask) 3156 | local room 3157 | -- WeeChat uses * as a mask for current buffer 3158 | if mask == '*' then 3159 | room = SERVER:findRoom(current_buffer) 3160 | else 3161 | for id, r in pairs(SERVER.rooms) do 3162 | -- Send /msg to a ID 3163 | if id == mask then 3164 | room = r 3165 | break 3166 | elseif mask == r.name then 3167 | room = r 3168 | break 3169 | end 3170 | end 3171 | end 3172 | 3173 | if room then 3174 | room:Msg(msg) 3175 | return w.WEECHAT_RC_OK_EAT 3176 | else 3177 | return w.WEECHAT_RC_OK 3178 | end 3179 | end 3180 | 3181 | function encrypt_command_cb(data, current_buffer, args) 3182 | local room = SERVER:findRoom(current_buffer) 3183 | if room then 3184 | local _, arg = split_args(args) 3185 | if arg == 'on' then 3186 | mprint('Enabling encryption for outgoing messages in room ' .. tostring(room.name)) 3187 | room:Encrypt() 3188 | elseif arg == 'off' then 3189 | mprint('Disabling encryption for outgoing messages in room ' .. tostring(room.name)) 3190 | room.encrypted = false 3191 | else 3192 | w.print(current_buffer, 'Use /encrypt on or /encrypt off to turn encryption on or off') 3193 | end 3194 | return w.WEECHAT_RC_OK_EAT 3195 | else 3196 | return w.WEECHAT_RC_OK 3197 | end 3198 | end 3199 | 3200 | function public_command_cb(data, current_buffer, args) 3201 | local room = SERVER:findRoom(current_buffer) 3202 | if room then 3203 | mprint('Marking room as public: ' .. tostring(room.name)) 3204 | room:public() 3205 | return w.WEECHAT_RC_OK_EAT 3206 | else 3207 | mprint('Run command from a room') 3208 | return w.WEECHAT_RC_OK 3209 | end 3210 | end 3211 | 3212 | function names_command_cb(cbdata, current_buffer, args) 3213 | local room = SERVER:findRoom(current_buffer) 3214 | if room then 3215 | local nrcolor = function(nr) 3216 | return wcolor'weechat.color.chat_channel' 3217 | .. tostring(nr) 3218 | .. default_color 3219 | end 3220 | local buffer_name = nrcolor(w.buffer_get_string(room.buffer, 'name')) 3221 | local delim_c = wcolor'weechat.color.chat_delimiters' 3222 | local tags = 'no_highlight,no_log,irc_names' 3223 | local pcolor = wcolor'weechat.color.chat_prefix_network' 3224 | local ngroups = {} 3225 | local nicks = {} 3226 | for id, name in pairs(room.users) do 3227 | local ncolor 3228 | if id == SERVER.user_id then 3229 | ncolor = w.color('chat_nick_self') 3230 | else 3231 | ncolor = w.info_get('irc_nick_color', name) 3232 | end 3233 | local ngroup, nprefix, nprefix_color = room:GetNickGroup(id) 3234 | if nprefix == ' ' then nprefix = '' end 3235 | nicks[#nicks+1] = ('%s%s%s%s'):format( 3236 | w.color(nprefix_color), 3237 | nprefix, 3238 | ncolor, 3239 | name 3240 | ) 3241 | if not ngroups[ngroup] then 3242 | ngroups[ngroup] = 0 3243 | end 3244 | ngroups[ngroup] = ngroups[ngroup] + 1 3245 | end 3246 | local line1 = ('%s--\tNicks %s: %s[%s%s]'):format( 3247 | pcolor, 3248 | buffer_name, 3249 | delim_c, 3250 | table.concat(nicks, ' '), 3251 | delim_c 3252 | ) 3253 | w.print_date_tags(room.buffer, 0, tags, line1) 3254 | local line2 = ( 3255 | '%s--\tChannel %s: %s nicks %s(%s%s ops, %s voice, %s normals%s)' 3256 | ):format( 3257 | pcolor, 3258 | buffer_name, 3259 | nrcolor(room.member_count), 3260 | delim_c, 3261 | default_color, 3262 | nrcolor((ngroups[1] or 0) + (ngroups[2] or 0)), 3263 | nrcolor(ngroups[3] or 0), 3264 | nrcolor((ngroups[4] or 0) + (ngroups[5] or 0)), 3265 | delim_c 3266 | ) 3267 | w.print_date_tags(room.buffer, 0, tags, line2) 3268 | return w.WEECHAT_RC_OK_EAT 3269 | else 3270 | perr('Could not find room') 3271 | return w.WEECHAT_RC_OK 3272 | end 3273 | end 3274 | 3275 | function more_command_cb(data, current_buffer, args) 3276 | local room = SERVER:findRoom(current_buffer) 3277 | if room then 3278 | SERVER:getMessages(room.identifier, 'b', room.prev_batch, 120) 3279 | return w.WEECHAT_RC_OK_EAT 3280 | else 3281 | perr('/more Could not find room') 3282 | end 3283 | return w.WEECHAT_RC_OK 3284 | end 3285 | 3286 | function roominfo_command_cb(data, current_buffer, args) 3287 | local room = SERVER:findRoom(current_buffer) 3288 | if room then 3289 | dbg{room=room} 3290 | return w.WEECHAT_RC_OK_EAT 3291 | else 3292 | perr('/roominfo Could not find room') 3293 | end 3294 | return w.WEECHAT_RC_OK 3295 | end 3296 | 3297 | function name_command_cb(data, current_buffer, args) 3298 | local room = SERVER:findRoom(current_buffer) 3299 | if room then 3300 | local _, name = split_args(args) 3301 | room:Name(name) 3302 | return w.WEECHAT_RC_OK_EAT 3303 | else 3304 | perr('/name Could not find room') 3305 | end 3306 | return w.WEECHAT_RC_OK 3307 | end 3308 | 3309 | function closed_matrix_buffer_cb(data, buffer) 3310 | BUFFER = nil 3311 | return w.WEECHAT_RC_OK 3312 | end 3313 | 3314 | function closed_matrix_room_cb(data, buffer) 3315 | -- WeeChat closed our room 3316 | local room = SERVER:findRoom(buffer) 3317 | if room then 3318 | room.buffer = nil 3319 | perr('Room got closed: '..room.name) 3320 | SERVER.rooms[room.identifier] = nil 3321 | return w.WEECHAT_RC_OK 3322 | end 3323 | return w.WEECHAT_RC_ERR 3324 | end 3325 | 3326 | function typing_notification_cb(signal, sig_type, data) 3327 | -- Ignore commands 3328 | if data:match'^/' then 3329 | return w.WEECHAT_RC_OK 3330 | end 3331 | -- Is this signal coming from a matrix buffer? 3332 | local room = SERVER:findRoom(data) 3333 | if room then 3334 | local input = w.buffer_get_string(data, "input") 3335 | -- Start sending when it reaches > 4 and doesn't start with command 3336 | if #input > 4 and not input:match'^/' then 3337 | local now = os.time() 3338 | -- Generate typing events every 4th second 3339 | if SERVER.typing_time + 4 < now then 3340 | SERVER.typing_time = now 3341 | room:SendTypingNotice() 3342 | end 3343 | end 3344 | end 3345 | 3346 | return w.WEECHAT_RC_OK 3347 | end 3348 | 3349 | function buffer_switch_cb(data, signal, sig_type) 3350 | -- Update bar item 3351 | w.bar_item_update('matrix_typing_notice') 3352 | if current_buffer then 3353 | local room = SERVER:findRoom(current_buffer) 3354 | if room then 3355 | room:MarkAsRead() 3356 | end 3357 | end 3358 | 3359 | current_buffer = w.current_buffer() 3360 | local room = SERVER:findRoom(current_buffer) 3361 | if room then 3362 | room:MarkAsRead() 3363 | end 3364 | return w.WEECHAT_RC_OK 3365 | end 3366 | 3367 | function typing_bar_item_cb(data, buffer, args) 3368 | local room = SERVER:findRoom(current_buffer) 3369 | if not room then return '' end 3370 | local typing_ids = table.concat(room.typing_ids, ' ') 3371 | if #typing_ids > 0 then 3372 | return "Typing: ".. typing_ids 3373 | end 3374 | return '' 3375 | end 3376 | 3377 | if w.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, SCRIPT_DESC, "matrix_unload", "UTF-8") then 3378 | -- Save WeeChat version to a global so other functionality can see it 3379 | local version = w.info_get('version_number', '') or 0 3380 | WEECHAT_VERSION = tonumber(version) 3381 | local settings = { 3382 | homeserver_url= {'https://matrix.org/', 'Full URL including port to your homeserver (including trailing slash) or use default matrix.org'}, 3383 | user= {'', 'Your homeserver username'}, 3384 | password= {'', 'Your homeserver password'}, 3385 | backlog_lines= {'120', 'Number of lines to fetch from backlog upon connecting'}, 3386 | presence_filter = {'off', 'Filter presence messages and ephemeral events (for performance)'}, 3387 | autojoin_on_invite = {'on', 'Automatically join rooms you are invited to'}, 3388 | typing_notices = {'on', 'Send typing notices when you type'}, 3389 | local_echo = {'on', 'Print lines locally instead of waiting for return from server'}, 3390 | debug = {'off', 'Print a lot of extra information to help with finding bugs and other problems.'}, 3391 | encrypted_message_color = {'lightgreen', 'Print encrypted mesages with this color'}, 3392 | --olm_secret = {'', 'Password used to secure olm stores'}, 3393 | timeout = {'5', 'Time in seconds until a connection is assumed to be timed out'}, 3394 | nick_style = {'nick', 'Show nicknames or user IDs in chat (\'nick\' or \'uid\')'}, 3395 | read_receipts = {'on', 'Send read receipts. Note that not sending them will prevent a room to be marked as read in Riot clients.'} 3396 | } 3397 | -- set default settings 3398 | for option, value in pairs(settings) do 3399 | if w.config_is_set_plugin(option) ~= 1 then 3400 | w.config_set_plugin(option, value[1]) 3401 | end 3402 | if WEECHAT_VERSION >= 0x00030500 then 3403 | w.config_set_desc_plugin(option, ('%s (default: "%s")'):format( 3404 | value[2], value[1])) 3405 | end 3406 | end 3407 | timeout = tonumber(w.config_get_plugin('timeout'))*1000 3408 | errprefix = wconf'weechat.look.prefix_error' 3409 | errprefix_c = wcolor'weechat.color.chat_prefix_error' 3410 | HOMEDIR = w.info_get('weechat_dir', '') .. '/' 3411 | local commands = { 3412 | 'join', 'part', 'leave', 'me', 'topic', 'upload', 'query', 'list', 3413 | 'op', 'voice', 'deop', 'devoice', 'kick', 'create', 'createalias', 'invite', 'nick', 3414 | 'whois', 'notice', 'msg', 'encrypt', 'public', 'names', 'more', 3415 | 'roominfo', 'name' 3416 | } 3417 | for _, c in pairs(commands) do 3418 | w.hook_command_run('/'..c, c..'_command_cb', '') 3419 | end 3420 | 3421 | if w.config_get_plugin('typing_notices') == 'on' then 3422 | w.hook_signal('input_text_changed', "typing_notification_cb", '') 3423 | end 3424 | 3425 | if w.config_get_plugin('debug') == 'on' then 3426 | DEBUG = true 3427 | end 3428 | 3429 | w.hook_config('plugins.var.lua.matrix.debug', 'configuration_changed_cb', '') 3430 | w.hook_config('plugins.var.lua.matrix.timeout', 'configuration_changed_cb', '') 3431 | 3432 | local cmds = {'help', 'connect', 'debug', 'msg'} 3433 | w.hook_command(SCRIPT_COMMAND, 'Plugin for matrix.org chat protocol', 3434 | '[command] [command options]', 3435 | 'Commands:\n' ..table.concat(cmds, '\n') .. 3436 | '\nUse /matrix help [command] to find out more\n' .. 3437 | '\nSupported slash commands (i.e. /commands):\n' .. 3438 | table.concat(commands, ', '), 3439 | -- Completions 3440 | table.concat(cmds, '|'), 3441 | 'matrix_command_cb', '') 3442 | 3443 | w.hook_command_run('/away -all*', 'matrix_away_command_run_cb', '') 3444 | SERVER = MatrixServer.create() 3445 | 3446 | if WEECHAT_VERSION < 0x01040000 then 3447 | perr(SCRIPT_NAME .. ': Please upgrade your WeeChat before using this script. Using this script on older WeeChat versions may lead to crashes. Many bugs have been fixed in newer versions of WeeChat.') 3448 | perr(SCRIPT_NAME .. ': Refusing to automatically connect you. If you insist, type /'..SCRIPT_COMMAND..' connect, and do not act surprised if it crashes :-)') 3449 | else 3450 | SERVER:connect() 3451 | end 3452 | 3453 | w.hook_signal('buffer_switch', "buffer_switch_cb", "") 3454 | w.bar_item_new('matrix_typing_notice', 'typing_bar_item_cb', '') 3455 | end 3456 | -------------------------------------------------------------------------------- /olm.lua: -------------------------------------------------------------------------------- 1 | -- libolm ffi wrapper for Lua(JIT) 2 | --[[ 3 | -- Copyright 2015-2016 Tor Hveem 4 | -- 5 | /* Copyright 2016 OpenMarket Ltd 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | --]] 20 | local ffi = require'ffi' 21 | 22 | ffi.cdef[[ 23 | typedef struct { 24 | void * memory; 25 | } OlmAccount ; 26 | size_t olm_account_size(); 27 | size_t olm_create_account( 28 | OlmAccount * account, 29 | void const * random, size_t random_length 30 | ); 31 | OlmAccount * olm_account( 32 | void * memory 33 | ); 34 | size_t olm_create_account_random_length( 35 | OlmAccount * account 36 | ); 37 | size_t olm_account_identity_keys( 38 | OlmAccount * account, 39 | void * identity_keys, size_t identity_key_length 40 | ); 41 | size_t olm_account_identity_keys_length( 42 | OlmAccount * account 43 | ); 44 | size_t olm_account_signature_length( 45 | OlmAccount * account 46 | ); 47 | size_t olm_account_sign( 48 | OlmAccount * account, 49 | void const * message, size_t message_length, 50 | void * signature, size_t signature_length 51 | ); 52 | typedef struct { 53 | void * memory; 54 | } OlmSession ; 55 | OlmSession * olm_session( 56 | void * memory 57 | ); 58 | size_t olm_session_size(); 59 | size_t olm_account_generate_one_time_keys_random_length( 60 | OlmAccount * account, 61 | size_t number_of_keys 62 | ); 63 | size_t olm_account_generate_one_time_keys( 64 | OlmAccount * account, 65 | size_t number_of_keys, 66 | void const * random, size_t random_length 67 | ); 68 | size_t olm_account_one_time_keys_length( 69 | OlmAccount * account 70 | ); 71 | size_t olm_account_one_time_keys( 72 | OlmAccount * account, 73 | void * one_time_keys, size_t one_time_keys_length 74 | ); 75 | size_t olm_create_outbound_session_random_length( 76 | OlmSession * session 77 | ); 78 | size_t olm_create_outbound_session( 79 | OlmSession * session, 80 | OlmAccount * account, 81 | void const * their_identity_key, size_t their_identity_key_length, 82 | void const * their_one_time_key, size_t their_one_time_key_length, 83 | void const * random, size_t random_length 84 | ); 85 | size_t olm_encrypt_random_length( 86 | OlmSession * session 87 | ); 88 | size_t olm_encrypt_message_type( 89 | OlmSession * session 90 | ); 91 | size_t olm_encrypt_message_length( 92 | OlmSession * session, 93 | size_t plaintext_length 94 | ); 95 | size_t olm_encrypt( 96 | OlmSession * session, 97 | void const * plaintext, size_t plaintext_length, 98 | void const * random, size_t random_length, 99 | void * message, size_t message_length 100 | ); 101 | size_t olm_create_inbound_session( 102 | OlmSession * session, 103 | OlmAccount * account, 104 | void * one_time_key_message, size_t message_length 105 | ); 106 | size_t olm_create_inbound_session_from( 107 | OlmSession * session, 108 | OlmAccount * account, 109 | void const * their_identity_key, size_t their_identity_key_length, 110 | void * one_time_key_message, size_t message_length 111 | ); 112 | size_t olm_decrypt_max_plaintext_length( 113 | OlmSession * session, 114 | size_t message_type, 115 | void * message, size_t message_length 116 | ); 117 | size_t olm_decrypt( 118 | OlmSession * session, 119 | size_t message_type, 120 | void * message, size_t message_length, 121 | void * plaintext, size_t max_plaintext_length 122 | ); 123 | size_t olm_pickle_account_length( 124 | OlmAccount * account 125 | ); 126 | size_t olm_pickle_session_length( 127 | OlmSession * session 128 | ); 129 | size_t olm_pickle_account( 130 | OlmAccount * account, 131 | void const * key, size_t key_length, 132 | void * pickled, size_t pickled_length 133 | ); 134 | size_t olm_pickle_session( 135 | OlmSession * session, 136 | void const * key, size_t key_length, 137 | void * pickled, size_t pickled_length 138 | ); 139 | size_t olm_unpickle_account( 140 | OlmAccount * account, 141 | void const * key, size_t key_length, 142 | void * pickled, size_t pickled_length 143 | ); 144 | size_t olm_unpickle_session( 145 | OlmSession * session, 146 | void const * key, size_t key_length, 147 | void * pickled, size_t pickled_length 148 | ); 149 | size_t olm_matches_inbound_session( 150 | OlmSession * session, 151 | void * one_time_key_message, size_t message_length 152 | ); 153 | size_t olm_error(); 154 | const char * olm_session_last_error( 155 | OlmSession * session 156 | ); 157 | const char * olm_account_last_error( 158 | OlmAccount * account 159 | ); 160 | size_t olm_clear_account( 161 | OlmAccount * account 162 | ); 163 | size_t olm_clear_session( 164 | OlmSession * session 165 | ); 166 | size_t olm_session_id_length( 167 | OlmSession * session 168 | ); 169 | size_t olm_session_id( 170 | OlmSession * session, 171 | void * id, size_t id_length 172 | ); 173 | size_t olm_account_mark_keys_as_published( 174 | OlmAccount * account 175 | ); 176 | size_t olm_remove_one_time_keys( 177 | OlmAccount * account, 178 | OlmSession * session 179 | ); 180 | 181 | 182 | typedef struct OlmOutboundGroupSession OlmOutboundGroupSession; 183 | size_t olm_outbound_group_session_size(); 184 | OlmOutboundGroupSession * olm_outbound_group_session( 185 | void *memory 186 | ); 187 | const char *olm_outbound_group_session_last_error( 188 | const OlmOutboundGroupSession *session 189 | ); 190 | size_t olm_pickle_outbound_group_session_length( 191 | const OlmOutboundGroupSession *session 192 | ); 193 | size_t olm_pickle_outbound_group_session( 194 | OlmOutboundGroupSession *session, 195 | void const * key, size_t key_length, 196 | void * pickled, size_t pickled_length 197 | ); 198 | size_t olm_unpickle_outbound_group_session( 199 | OlmOutboundGroupSession *session, 200 | void const * key, size_t key_length, 201 | void * pickled, size_t pickled_length 202 | ); 203 | size_t olm_init_outbound_group_session_random_length( 204 | const OlmOutboundGroupSession *session 205 | ); 206 | size_t olm_init_outbound_group_session( 207 | OlmOutboundGroupSession *session, 208 | uint8_t const * random, size_t random_length 209 | ); 210 | size_t olm_group_encrypt_message_length( 211 | OlmOutboundGroupSession *session, 212 | size_t plaintext_length 213 | ); 214 | size_t olm_group_encrypt( 215 | OlmOutboundGroupSession *session, 216 | uint8_t const * plaintext, size_t plaintext_length, 217 | uint8_t * message, size_t message_length 218 | ); 219 | size_t olm_outbound_group_session_id_length( 220 | const OlmOutboundGroupSession *session 221 | ); 222 | size_t olm_outbound_group_session_id( 223 | OlmOutboundGroupSession *session, 224 | uint8_t * id, size_t id_length 225 | ); 226 | uint32_t olm_outbound_group_session_message_index( 227 | OlmOutboundGroupSession *session 228 | ); 229 | size_t olm_outbound_group_session_key_length( 230 | const OlmOutboundGroupSession *session 231 | ); 232 | size_t olm_outbound_group_session_key( 233 | OlmOutboundGroupSession *session, 234 | uint8_t * key, size_t key_length 235 | ); 236 | typedef struct OlmInboundGroupSession OlmInboundGroupSession; 237 | size_t olm_inbound_group_session_size(); 238 | OlmInboundGroupSession * olm_inbound_group_session( 239 | void *memory 240 | ); 241 | const char *olm_inbound_group_session_last_error( 242 | const OlmInboundGroupSession *session 243 | ); 244 | size_t olm_clear_inbound_group_session( 245 | OlmInboundGroupSession *session 246 | ); 247 | size_t olm_pickle_inbound_group_session_length( 248 | const OlmInboundGroupSession *session 249 | ); 250 | size_t olm_pickle_inbound_group_session( 251 | OlmInboundGroupSession *session, 252 | void const * key, size_t key_length, 253 | void * pickled, size_t pickled_length 254 | ); 255 | size_t olm_unpickle_inbound_group_session( 256 | OlmInboundGroupSession *session, 257 | void const * key, size_t key_length, 258 | void * pickled, size_t pickled_length 259 | ); 260 | size_t olm_init_inbound_group_session( 261 | OlmInboundGroupSession *session, 262 | uint32_t message_index, 263 | uint8_t const * session_key, size_t session_key_length 264 | ); 265 | size_t olm_group_decrypt_max_plaintext_length( 266 | OlmInboundGroupSession *session, 267 | uint8_t * message, size_t message_length 268 | ); 269 | size_t olm_group_decrypt( 270 | OlmInboundGroupSession *session, 271 | 272 | /* input; note that it will be overwritten with the base64-decoded 273 | message. */ 274 | uint8_t * message, size_t message_length, 275 | 276 | /* output */ 277 | uint8_t * plaintext, size_t max_plaintext_length 278 | ); 279 | 280 | ]] 281 | 282 | local olm = ffi.load('libolm') 283 | local ERR = olm.olm_error() 284 | 285 | local function create_string_buffer(obj, arg) 286 | -- most of the API calls return ULL which is of type cdata, we can convert to lua numbers using tonumber 287 | -- with regular Lua FFI type is userdata 288 | if type(arg) == 'number' or type(arg) == 'cdata' or type(arg) == 'userdata' then 289 | local buf = ffi.new("uint8_t[?]", tonumber(arg)) 290 | table.insert(obj.strings, buf) 291 | return buf 292 | end 293 | return arg 294 | end 295 | 296 | local function create_string(str, size) 297 | if not size then 298 | size = #str 299 | end 300 | local msg = ffi.new('uint8_t[?]', size) 301 | ffi.copy(msg, str, size) 302 | return msg 303 | end 304 | 305 | local function len(arg) 306 | -- Helper for porting from olm.py 307 | return #arg 308 | end 309 | 310 | local function read_random(n) 311 | local fd = io.open('/dev/urandom', 'rb') 312 | local rnd = fd:read(tonumber(n)) 313 | fd:close() 314 | return rnd 315 | end 316 | 317 | local Account = {} 318 | Account.__index = Account 319 | 320 | Account.new = function() 321 | local account = {} 322 | setmetatable(account, Account) 323 | local size = tonumber(olm.olm_account_size()) 324 | 325 | -- Save C string buffer to a table so the garbage collector does not clean 326 | -- the buffers before the olm library is done reading and writing to them 327 | -- TODO: check why this is needed 328 | account.strings = {} 329 | account.ptr = olm.olm_account(create_string_buffer(account, size)) 330 | return account 331 | end 332 | 333 | function Account:clear() 334 | local ret = olm.olm_clear_account(self.ptr) 335 | self.strings = {} 336 | return ret 337 | end 338 | 339 | function Account:last_error() 340 | return ffi.string(olm.olm_account_last_error(self.ptr)) 341 | end 342 | 343 | function Account:errcheck(val) 344 | if val == ERR then 345 | local err = self:last_error() 346 | return val, err 347 | end 348 | return val, nil 349 | end 350 | 351 | 352 | function Account:create() 353 | local random_length = tonumber(olm.olm_create_account_random_length(self.ptr)) 354 | local random = create_string(read_random(random_length), random_length) 355 | olm.olm_create_account(self.ptr, random, random_length) 356 | end 357 | 358 | function Account:identity_keys() 359 | local out_length, err = self:errcheck(olm.olm_account_identity_keys_length(self.ptr)) 360 | if err then 361 | return out_length, err 362 | end 363 | out_length = tonumber(out_length) 364 | local out_buffer = create_string_buffer(self, out_length) 365 | local _, ierr = self:errcheck(olm.olm_account_identity_keys(self.ptr, out_buffer, out_length)) 366 | if ierr then 367 | return '', ierr 368 | end 369 | local identity_keys = ffi.string(out_buffer, out_length) 370 | return identity_keys 371 | end 372 | 373 | function Account:sign(message) 374 | local out_length = tonumber(olm.olm_account_signature_length(self.ptr)) 375 | local message_buffer = create_string_buffer(self, message) 376 | local out_buffer = create_string_buffer(self, out_length) 377 | olm.olm_account_sign( 378 | self.ptr, message_buffer, len(message), out_buffer, out_length 379 | ) 380 | return ffi.string(out_buffer, out_length) 381 | end 382 | 383 | function Account:one_time_keys() 384 | local out_length = tonumber(olm.olm_account_one_time_keys_length(self.ptr)) 385 | local out_buffer = create_string_buffer(self, out_length) 386 | local _, err = olm.olm_account_one_time_keys(self.ptr, out_buffer, out_length) 387 | if err then return '', err end 388 | local out = ffi.string(out_buffer, out_length) 389 | return out 390 | end 391 | 392 | function Account:generate_one_time_keys(count) 393 | local random_length = tonumber(olm.olm_account_generate_one_time_keys_random_length(self.ptr, count)) 394 | local random = create_string(read_random(random_length), random_length) 395 | return self:errcheck(olm.olm_account_generate_one_time_keys( 396 | self.ptr, count, random, random_length 397 | )) 398 | end 399 | 400 | function Account:pickle(key) 401 | local key_buffer = create_string_buffer(self, key) 402 | local pickle_length = tonumber(olm.olm_pickle_account_length(self.ptr)) 403 | local pickle_buffer = create_string_buffer(self, pickle_length) 404 | local _, err = olm.olm_pickle_account( 405 | self.ptr, key_buffer, #key, pickle_buffer, pickle_length 406 | ) 407 | if err then 408 | return nil, err 409 | end 410 | return ffi.string(pickle_buffer, pickle_length) 411 | end 412 | 413 | function Account:unpickle(key, pickle) 414 | local pickle_buffer = create_string(pickle, #pickle) 415 | local ret, err = self:errcheck(olm.olm_unpickle_account( 416 | self.ptr, key, #key, pickle_buffer, #pickle 417 | )) 418 | return ret, err 419 | end 420 | 421 | function Account:mark_keys_as_published() 422 | return self:errcheck(olm.olm_account_mark_keys_as_published(self.ptr)) 423 | end 424 | 425 | function Account:remove_one_time_keys(session) 426 | return self:errcheck(olm.olm_remove_one_time_keys(self.ptr, session.ptr)) 427 | end 428 | 429 | local Session = {} 430 | Session.__index = Session 431 | 432 | Session.new = function() 433 | local session = {} 434 | setmetatable(session, Session) 435 | session.strings = {} 436 | local buf = create_string_buffer(session, tonumber(olm.olm_session_size())) 437 | session.ptr = olm.olm_session(buf) 438 | return session 439 | end 440 | 441 | function Session:clear() 442 | local ret, err = self:errcheck(olm.olm_clear_session(self.ptr)) 443 | if err then return nil, err end 444 | -- Save C string buffer to a table so the garbage collector does not clean 445 | -- the buffers before the olm library is done reading and writing to them 446 | -- TODO: check why this is needed 447 | self.strings = {} 448 | return ret 449 | end 450 | 451 | function Session:errcheck(val) 452 | if val == ERR then 453 | local err = self:last_error() 454 | return val, err 455 | end 456 | return val, nil 457 | end 458 | 459 | function Session:last_error() 460 | return ffi.string(olm.olm_session_last_error(self.ptr)) 461 | end 462 | 463 | function Session:create_outbound(account, identity_key, one_time_key) 464 | local r_length = olm.olm_create_outbound_session_random_length(self.ptr) 465 | local random = read_random(r_length) 466 | return self:errcheck(olm.olm_create_outbound_session( 467 | self.ptr, 468 | account.ptr, 469 | identity_key, #identity_key, 470 | one_time_key, #one_time_key, 471 | random, r_length 472 | )) 473 | end 474 | 475 | function Session:create_inbound(account, one_time_key_message) 476 | local msg = create_string(one_time_key_message) 477 | olm.olm_create_inbound_session( 478 | self.ptr, 479 | account.ptr, 480 | msg, #one_time_key_message 481 | ) 482 | end 483 | 484 | function Session:create_inbound_from(account, identity_key, one_time_key_message) 485 | local one_time_key_message_buffer = create_string(one_time_key_message) 486 | return self:errcheck(olm.olm_create_inbound_session_from( 487 | self.ptr, 488 | account.ptr, 489 | identity_key, #identity_key, 490 | one_time_key_message_buffer, #one_time_key_message 491 | )) 492 | end 493 | 494 | function Session:matches_inbound(one_time_key_message) 495 | local one_time_key_message_buffer = create_string(one_time_key_message) 496 | local matches = olm.olm_matches_inbound_session( 497 | self.ptr, 498 | one_time_key_message_buffer, len(one_time_key_message) 499 | ) 500 | if tonumber(matches) == 1 then 501 | return true 502 | end 503 | return false 504 | end 505 | 506 | function Session:session_id() 507 | local id_length = tonumber(olm.olm_session_id_length(self.ptr)) 508 | local id_buffer = create_string_buffer(self, id_length) 509 | local ret, err = self:errcheck(olm.olm_session_id(self.ptr, id_buffer, id_length)) 510 | if err then return ret, err end 511 | return ffi.string(id_buffer, id_length) 512 | end 513 | 514 | function Session:encrypt(plaintext) 515 | local r_length = olm.olm_encrypt_random_length(self.ptr) 516 | local random = read_random(r_length) 517 | 518 | local message_type = tonumber(olm.olm_encrypt_message_type(self.ptr)) 519 | local message_length = tonumber(olm.olm_encrypt_message_length( 520 | self.ptr, #plaintext 521 | )) 522 | local message_buffer = create_string_buffer(self, message_length) 523 | 524 | olm.olm_encrypt( 525 | self.ptr, 526 | plaintext, #plaintext, 527 | random, r_length, 528 | message_buffer, message_length 529 | ) 530 | message_buffer = ffi.string(message_buffer, message_length) 531 | return message_type, message_buffer 532 | end 533 | 534 | function Session:decrypt(message_type, message) 535 | local maxlen_message_buffer = create_string(message) 536 | local max_plaintext_length, err = self:errcheck(olm.olm_decrypt_max_plaintext_length( 537 | self.ptr, message_type, maxlen_message_buffer, #message 538 | )) 539 | if err then return nil, err end 540 | max_plaintext_length = tonumber(max_plaintext_length) 541 | local plaintext_buffer = create_string_buffer(self, max_plaintext_length) 542 | local message_buffer = create_string(message) 543 | local plaintext_length, perr = self:errcheck(olm.olm_decrypt( 544 | self.ptr, message_type, message_buffer, #message, 545 | plaintext_buffer, max_plaintext_length 546 | )) 547 | if perr then return nil, perr end 548 | local plaintext = ffi.string(plaintext_buffer, tonumber(plaintext_length)) 549 | return plaintext 550 | end 551 | 552 | function Session:pickle(key) 553 | local key_buffer = create_string_buffer(self, key) 554 | local pickle_length = tonumber(olm.olm_pickle_session_length(self.ptr)) 555 | local pickle_buffer = create_string_buffer(self, pickle_length) 556 | olm.olm_pickle_session( 557 | self.ptr, key_buffer, len(key), pickle_buffer, pickle_length 558 | ) 559 | return ffi.string(pickle_buffer, pickle_length) 560 | end 561 | 562 | function Session:unpickle(key, pickle) 563 | local pickle_buffer = create_string(pickle, #pickle) 564 | local ret = olm.olm_unpickle_session( 565 | self.ptr, key, #key, pickle_buffer, #pickle 566 | ) 567 | return self:errcheck(ret) 568 | end 569 | 570 | local OutboundGroupSession = {} 571 | OutboundGroupSession.__index = OutboundGroupSession 572 | 573 | OutboundGroupSession.new = function() 574 | local session = {} 575 | setmetatable(session, OutboundGroupSession) 576 | session.strings = {} 577 | local buf = create_string_buffer(session, tonumber(olm.olm_outbound_group_session_size())) 578 | session.ptr = olm.olm_outbound_group_session(buf) 579 | 580 | local random_length = tonumber(olm.olm_init_outbound_group_session_random_length(session.ptr)) 581 | local random = create_string(read_random(random_length), random_length) 582 | olm.olm_init_outbound_group_session(session.ptr, random, random_length) 583 | return session 584 | end 585 | 586 | function OutboundGroupSession:pickle(key) 587 | local key_buffer = create_string_buffer(self, key) 588 | local pickle_length = tonumber(olm.olm_pickle_outbound_group_session_length(self.ptr)) 589 | local pickle_buffer = create_string_buffer(self, pickle_length) 590 | olm.olm_pickle_outbound_group_session( 591 | self.ptr, key_buffer, len(key), pickle_buffer, pickle_length 592 | ) 593 | return ffi.string(pickle_buffer, pickle_length) 594 | end 595 | 596 | function OutboundGroupSession:unpickle(key, pickle) 597 | local pickle_buffer = create_string(pickle, #pickle) 598 | local ret = olm.olm_unpickle_outbound_group_session( 599 | self.ptr, key, #key, pickle_buffer, #pickle 600 | ) 601 | return self:errcheck(ret) 602 | end 603 | 604 | function OutboundGroupSession:session_id() 605 | local id_length = tonumber(olm.olm_outbound_group_session_id_length(self.ptr)) 606 | local id_buffer = create_string_buffer(self, id_length) 607 | local ret, err = self:errcheck(olm.olm_outbound_group_session_id(self.ptr, id_buffer, id_length)) 608 | if err then return ret, err end 609 | return ffi.string(id_buffer, id_length) 610 | end 611 | 612 | function OutboundGroupSession:encrypt(plaintext) 613 | local message_length = tonumber(olm.olm_group_encrypt_message_length( 614 | self.ptr, #plaintext 615 | )) 616 | local message_buffer = create_string_buffer(self, message_length) 617 | 618 | olm.olm_group_encrypt( 619 | self.ptr, 620 | plaintext, #plaintext, 621 | message_buffer, message_length 622 | ) 623 | message_buffer = ffi.string(message_buffer, message_length) 624 | return message_buffer 625 | end 626 | 627 | function OutboundGroupSession:message_index() 628 | local index = olm.olm_outbound_group_session_message_index(self.ptr) 629 | return index 630 | end 631 | 632 | function OutboundGroupSession:session_key() 633 | local key_length = olm.olm_outbound_group_session_key_length(self.ptr) 634 | local key_buffer = create_string_buffer(self, key_length) 635 | olm.olm_outbound_group_session_key(self.ptr, key_buffer, key_length) 636 | return ffi.string(key_buffer, key_length) 637 | end 638 | 639 | function OutboundGroupSession:clear() 640 | --local ret, err = self:errcheck(olm.olm_clear_session(self.ptr)) 641 | --if err then return nil, err end 642 | -- Save C string buffer to a table so the garbage collector does not clean 643 | -- the buffers before the olm library is done reading and writing to them 644 | -- TODO: check why this is needed 645 | self.strings = {} 646 | --return ret 647 | end 648 | 649 | function OutboundGroupSession:errcheck(val) 650 | if val == ERR then 651 | local err = self:last_error() 652 | return val, err 653 | end 654 | return val, nil 655 | end 656 | 657 | function OutboundGroupSession:last_error() 658 | return ffi.string(olm.olm_outbound_group_session_last_error(self.ptr)) 659 | end 660 | 661 | local InboundGroupSession = {} 662 | InboundGroupSession.__index = InboundGroupSession 663 | 664 | InboundGroupSession.new = function() 665 | local session = {} 666 | setmetatable(session, InboundGroupSession) 667 | session.strings = {} 668 | local buf = create_string_buffer(session, tonumber(olm.olm_inbound_group_session_size())) 669 | session.ptr = olm.olm_inbound_group_session(buf) 670 | 671 | return session 672 | end 673 | 674 | function InboundGroupSession:pickle(key) 675 | local key_buffer = create_string_buffer(self, key) 676 | local pickle_length = tonumber(olm.olm_pickle_inbound_group_session_length(self.ptr)) 677 | local pickle_buffer = create_string_buffer(self, pickle_length) 678 | olm.olm_pickle_inbound_group_session( 679 | self.ptr, key_buffer, len(key), pickle_buffer, pickle_length 680 | ) 681 | return ffi.string(pickle_buffer, pickle_length) 682 | end 683 | 684 | function InboundGroupSession:unpickle(key, pickle) 685 | local pickle_buffer = create_string(pickle, #pickle) 686 | local ret = olm.olm_unpickle_inbound_group_session( 687 | self.ptr, key, #key, pickle_buffer, #pickle 688 | ) 689 | return self:errcheck(ret) 690 | end 691 | 692 | function InboundGroupSession:init(message_index, session_key) 693 | local key_buffer = create_string_buffer(self, session_key) 694 | return self:errcheck(olm.olm_init_inbound_group_session(self.ptr, message_index, key_buffer, #session_key)) 695 | end 696 | 697 | function InboundGroupSession:decrypt(message) 698 | local maxlen_message_buffer = create_string(message) 699 | local max_plaintext_length, err = self:errcheck(olm.olm_group_decrypt_max_plaintext_length( 700 | self.ptr, maxlen_message_buffer, #message 701 | )) 702 | if err then return nil, err end 703 | max_plaintext_length = tonumber(max_plaintext_length) 704 | local plaintext_buffer = create_string_buffer(self, max_plaintext_length) 705 | local message_buffer = create_string(message) 706 | local plaintext_length, perr = self:errcheck(olm.olm_group_decrypt( 707 | self.ptr, message_buffer, #message, 708 | plaintext_buffer, max_plaintext_length 709 | )) 710 | if perr then return nil, err end 711 | local plaintext = ffi.string(plaintext_buffer, tonumber(plaintext_length)) 712 | return plaintext 713 | end 714 | 715 | 716 | function InboundGroupSession:clear() 717 | --local ret, err = self:errcheck(olm.olm_clear_session(self.ptr)) 718 | --if err then return nil, err end 719 | -- Save C string buffer to a table so the garbage collector does not clean 720 | -- the buffers before the olm library is done reading and writing to them 721 | -- TODO: check why this is needed 722 | self.strings = {} 723 | --return ret 724 | end 725 | 726 | function InboundGroupSession:errcheck(val) 727 | if val == ERR then 728 | local err = self:last_error() 729 | return val, err 730 | end 731 | return val, nil 732 | end 733 | 734 | function InboundGroupSession:last_error() 735 | return ffi.string(olm.olm_inbound_group_session_last_error(self.ptr)) 736 | end 737 | 738 | -- Invoke program with --test to run tests 739 | local test = arg and arg[1] and arg[1] == '--test' 740 | if test then 741 | local json = require'cjson' 742 | local key = 'test' 743 | local err 744 | local _ 745 | local alice 746 | local bob 747 | 748 | alice = Account.new() 749 | local a_session = Session.new() 750 | bob = Account.new() 751 | local b_session = Session.new() 752 | 753 | alice:create() 754 | 755 | 756 | 757 | local pickle = alice:pickle(key) 758 | 759 | local a_keys = json.decode(alice:identity_keys()) 760 | 761 | alice = Account.new() 762 | alice:unpickle(key, pickle) 763 | local a_keys_2 = json.decode(alice:identity_keys()) 764 | assert(a_keys.curve25519 == a_keys_2.curve25519) 765 | 766 | _, err = alice:unpickle('invalid key', pickle) 767 | assert(err, 'BAD_ACCOUNT_KEY') 768 | 769 | pickle = a_session:pickle(key) 770 | a_session:unpickle(key, pickle) 771 | 772 | _, err = a_session:unpickle(key, 'invalid base64') 773 | assert(err, 'BAD_ACCOUNT_KEY') 774 | _, err = a_session:unpickle('invalid key', pickle) 775 | assert(err, 'BAD_ACCOUNT_KEY') 776 | _, err = a_session:unpickle('invalid key', 'invalid bad64') 777 | assert(err, 'INVALID_BASE64') 778 | 779 | local sign_message = 'yepyepyep' 780 | local signed = alice:sign(sign_message) 781 | print('signed', signed) 782 | 783 | bob:create() 784 | -- luacheck: ignore 785 | local bobs_id_keys = json.decode(bob:identity_keys()) 786 | bob:generate_one_time_keys(50) 787 | local bobs_id_keys = json.decode(bob:identity_keys()) 788 | local bobs_id_key = bobs_id_keys.curve25519 789 | local bobs_ot_keys = json.decode(bob:one_time_keys()) 790 | local bobs_ot_key 791 | for _,k in pairs(bobs_ot_keys.curve25519) do 792 | bobs_ot_key = k 793 | end 794 | a_session:create_outbound(alice, bobs_id_key, bobs_ot_key) 795 | bob:remove_one_time_keys(b_session) 796 | local secret_message = 'why not zoidberg?' 797 | message_1_type, message_1_body = a_session:encrypt(secret_message) 798 | 799 | b_session:create_inbound(bob, message_1_body) 800 | print('Matches inbound:', assert(b_session:matches_inbound(message_1_body))) 801 | local decrypted = b_session:decrypt(message_1_type, message_1_body) 802 | print( 'Decrypted message: ', decrypted) 803 | assert(secret_message == decrypted) 804 | 805 | 806 | b_session:create_inbound_from(bob, a_keys.curve25519, message_1_body) 807 | 808 | bob:mark_keys_as_published() 809 | print('A session id: ', a_session:session_id()) 810 | for i=1,10000 do 811 | Account.new():clear() 812 | end 813 | 814 | print('*** GROUP TESTS *** ') 815 | 816 | local g_session = OutboundGroupSession.new() 817 | local pickle = g_session:pickle(key) 818 | local _ = g_session:unpickle(key, pickle) 819 | local message_index = g_session:message_index() 820 | local session_key = g_session:session_key() 821 | print('Group session id:', g_session:session_id()) 822 | print('Group session index:', g_session:message_index()) 823 | print('Group session key:', g_session:session_key()) 824 | 825 | local i_session = InboundGroupSession.new() 826 | i_session:init(message_index, session_key) 827 | print('Group decrypt', assert(secret_message == i_session:decrypt(g_session:encrypt(secret_message)))) 828 | 829 | 830 | alice:clear() 831 | bob:clear() 832 | a_session:clear() 833 | b_session:clear() 834 | g_session:clear() 835 | i_session:clear() 836 | --print('Temp strings: '.. tostring(#strings)) 837 | --strings = {} 838 | end 839 | local test2 = arg and arg[1] and arg[1] == '--decrypt' 840 | if test2 then 841 | local body = '' 842 | local alice 843 | local bob 844 | local OLM_KEY = '' 845 | local json = require'cjson' 846 | 847 | local fread = function(fname) 848 | local fd = io.open(fname, 'r') 849 | local data = fd:read('*a') 850 | fd:close() 851 | return data 852 | end 853 | 854 | alice = Account.new() 855 | --print(json.encode(arg)) 856 | alice:unpickle(OLM_KEY, fread(arg[2])) 857 | 858 | local sessions = json.decode(fread(arg[3])) 859 | for id, pickle in pairs(sessions) do 860 | local session = Session.new() 861 | session:unpickle(OLM_KEY, pickle) 862 | print('matches', session:matches_inbound(body)) 863 | local matches = session:matches_inbound(body) 864 | if matches then 865 | local cleartext, err = session:decrypt(0, body) 866 | print(session:decrypt(0, body)) 867 | end 868 | end 869 | --bob = Account.new() 870 | --local b_session = Session.new() 871 | 872 | end 873 | 874 | return { 875 | Account=Account, 876 | Session=Session, 877 | OutboundGroupSession=OutboundGroupSession, 878 | InboundGroupSession=InboundGroupSession, 879 | } 880 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Just a simple script to push an updated copy of the matrix.lua script 3 | # to the .weechat/lua folder after each update. 4 | file="$HOME/.weechat/lua/matrix.lua" 5 | link="$HOME/.weechat/lua/autoload/matrix.lua" 6 | 7 | echo "Updating, please wait a moment..." 8 | git pull 9 | 10 | # copy in the updated matrix.lua 11 | echo "Updating $file." 12 | cp matrix.lua $HOME/.weechat/lua 13 | 14 | # create the symlink if necessary 15 | if [ -h $link ] 16 | then 17 | echo "$link already exists, skipping symbolic link creation." 18 | else 19 | echo "Creating symbolic link in autoload directory." 20 | ln -s $file $link 21 | fi 22 | echo "Done." 23 | --------------------------------------------------------------------------------