Initial import.
[citadel.git] / gaim-citadel / citadel.lua
1 -- citadel.lua
2 -- Gaim Citadel plugin.
3 --
4 -- © 2006 David Given.
5 -- This code is licensed under the GPL v2. See the file COPYING in this
6 -- directory for the full license text.
7 --
8 -- $Id: auth.c 4258 2006-01-29 13:34:44 +0000 (Sun, 29 Jan 2006) dothebart $
9
10 -----------------------------------------------------------------------------
11 --                                 GLOBALS                                 --
12 -----------------------------------------------------------------------------
13
14 local _
15 local username, servername, port
16 local ga, gc
17 local fd, gsc
18 local timerhandle
19 local buddies = {}
20
21 -----------------------------------------------------------------------------
22 --                                CONSTANTS                                --
23 -----------------------------------------------------------------------------
24
25 -- Special values returned as Citadel's response codes.
26
27 local LISTING_FOLLOWS         = 100
28 local CIT_OK                  = 200
29 local MORE_DATA               = 300
30 local SEND_LISTING            = 400
31 local ERROR                   = 500
32 local BINARY_FOLLOWS          = 600
33 local SEND_BINARY             = 700
34 local START_CHAT_MODE         = 800
35
36 local INTERNAL_ERROR          = 10
37 local TOO_BIG                 = 11
38 local ILLEGAL_VALUE           = 12
39 local NOT_LOGGED_IN           = 20
40 local CMD_NOT_SUPPORTED       = 30
41 local PASSWORD_REQUIRED       = 40
42 local ALREADY_LOGGED_IN       = 41
43 local USERNAME_REQUIRED       = 42
44 local HIGHER_ACCESS_REQUIRED  = 50
45 local MAX_SESSIONS_EXCEEDED   = 51
46 local RESOURCE_BUSY           = 52
47 local RESOURCE_NOT_OPEN       = 53
48 local NOT_HERE                = 60
49 local INVALID_FLOOR_OPERATION = 61
50 local NO_SUCH_USER            = 70
51 local FILE_NOT_FOUND          = 71
52 local ROOM_NOT_FOUND          = 72
53 local NO_SUCH_SYSTEM          = 73
54 local ALREADY_EXISTS          = 74
55 local MESSAGE_NOT_FOUND       = 75
56
57 local ASYNC_MSG               = 900
58 local ASYNC_GEXP              = 02
59
60 -- Other Citadel settings.
61
62 local CITADEL_DEFAULT_PORT    = 504
63 local CITADEL_CONFIG_ROOM     = "My Citadel Config"
64 local CITADEL_BUDDY_MSG       = "__ Buddy List __"
65 local CITADEL_POLL_INTERVAL   = 5
66
67 -----------------------------------------------------------------------------
68 --                                UTILITIES                                --
69 -----------------------------------------------------------------------------
70
71 --local stderr = io.stderr
72
73 local function log(...)
74         local s = {}
75         for _, i in ipairs(arg) do
76                 table.insert(s, tostring(i))
77         end
78         print("citadel: lua: "..table.concat(s))
79 end
80
81 local function unexpectederror()
82         error("The Citadel server said something unexpected. Giving up.")
83 end
84
85 local function warning(...)
86         local s = {}
87         for _, i in ipairs(arg) do
88                 table.insert(s, tostring(i))
89         end
90         gaim_connection_notice(gc, s)
91 end
92
93 local olderror = error
94 error = function(e)
95         log("error: ", e)
96         log("traceback: ", debug.traceback())
97         olderror(e)
98 end
99
100 -----------------------------------------------------------------------------
101 --                                SCHEDULER                                --
102 -----------------------------------------------------------------------------
103
104 local taskqueue = {}
105 local idle
106 local inscheduler = false
107
108 local yield = coroutine.yield
109
110 local function schedule_now()
111         if not inscheduler then
112                 inscheduler = true
113                 
114                 while taskqueue[1] do
115                         -- Pull the first task off the queue, creating it if necessary.
116                 
117                         local task = taskqueue[1]
118                         if (type(task) == "function") then
119                                 task = coroutine.create(task)
120                                 taskqueue[1] = task
121                         end
122                         
123                         -- Run it.
124                         
125                         local s, e = coroutine.resume(task)
126                         if not s then
127                                 log("error: ", e)
128                                 log("traceback: ", debug.traceback())
129                                 gaim_connection_error(gc, e)
130                         end
131         
132                         -- If it's not dead, then it must have yielded --- return back to C.
133                                 
134                         if (coroutine.status(task) ~= "dead") then
135                                 break
136                         end
137                         
138                         -- Otherwise, remove it from the queue and go again.
139                         
140                         table.remove(taskqueue, 1)
141                 end
142                 
143                 inscheduler = false
144         end
145 end
146
147 local function queue(func)
148         table.insert(taskqueue, func)
149 --[[
150         table.insert(taskqueue, function()
151                 local i, e = pcall(func)
152                 if not i then
153                         log("coroutine died with error! ", e)
154                         gaim_connection_error(gc, e)
155                 end
156         end)
157 --]]
158 end
159
160 local queued = {}
161 local function lazyqueue(func)
162         if not queued[func] then
163                 queued[func] = true
164                 queue(
165                         function()
166                                 queued[func] = nil
167                                 func()
168                         end)
169         end
170 end
171
172 -----------------------------------------------------------------------------
173 --                             INPUT MANGLING                              --
174 -----------------------------------------------------------------------------
175
176 local inputbuffer = ""
177
178 -- Read a single line of text from the server, maing Lua's coroutines do the
179 -- vast bulk of the work of managing Gaim's state machine for us. Woo!
180
181 local function readline()
182         -- Always yield at least once. Otherwise, Lua hogs all the CPU time.
183
184         yield()
185
186         while true do
187                 if fd then
188                         -- Read some data from the remote server, if any's
189                         -- available.
190
191                         local i = interface_readdata(fd, gsc)
192                         if not i then
193                                 error("Unexpected disconnection from Citadel server")
194                         end
195
196                         inputbuffer = inputbuffer..i
197
198                         -- Have we read a complete line of text?
199
200                         local s, e, l = string.find(inputbuffer, "^([^\n]*)\n")
201                         if l then
202                                 -- If so, return it.
203
204                                 inputbuffer = string.sub(inputbuffer, e+1)
205                                 return l
206                         end
207                 end
208
209                 -- Otherwise, wait some more.
210         
211                 yield()
212         end
213 end
214
215 local function unpack_citadel_data_line(s, a)
216         a = a or {}
217         for i in string.gfind(s, "([^|]*)|?") do
218                 table.insert(a, i)
219         end
220         return a
221 end
222
223 -- Read in an parse a packet from the Citadel server.
224
225 local function get_response()
226         local message = {}
227
228         -- The first line of a message is of the format:
229         --   123 String|String|String
230         --
231         -- The 123 is a response code.
232                 
233         local s = readline()
234         message.response = tonumber(string.sub(s, 1, 3))
235         
236         s = string.sub(s, 5)
237         unpack_citadel_data_line(s, message)
238         
239         -- If the response code is LISTING_FOLLOWS, then there's more data
240         -- coming.
241         
242         if (message.response == LISTING_FOLLOWS) then
243                 message.xargs = {}
244                 
245                 while true do
246                         s = readline()
247                         if (s == "000") then
248                                 break
249                         end
250                         --log("Got xarg: ", s)
251                         table.insert(message.xargs, s)
252                 end
253         end
254         
255         -- If the response code is BINARY_FOLLOWS, there's a big binary chunk
256         -- coming --- which we don't support.
257         
258         if (message.response == BINARY_FOLLOWS) then
259                 error("Server sent a binary chunk, which we don't support yet")
260         end
261         
262         return message
263 end
264
265 -----------------------------------------------------------------------------
266 --                            OUTPUT MANGLING                              --
267 -----------------------------------------------------------------------------
268
269 local function writeline(...)
270         local s = table.concat(arg)
271         
272         log("send: ", s)
273         interface_writedata(fd, gsc, s)
274         interface_writedata(fd, gsc, "\n")
275 end
276
277 -----------------------------------------------------------------------------
278 --                           PRESENCE MANAGEMENT                           --
279 -----------------------------------------------------------------------------
280
281 local function cant_save_buddy_list()
282         warning("Unable to send buddy list to server.")
283 end
284
285 local function save_buddy_list()
286         writeline("GOTO "..CITADEL_CONFIG_ROOM)
287         local m = get_response()
288         if (m.response ~= CIT_OK) then
289                 cant_save_buddy_list()
290                 return
291         end
292
293         -- Search and destroy any old buddy list.
294
295         writeline("MSGS ALL|0|1")
296         m = get_response()
297         if (m.response ~= START_CHAT_MODE) then
298                 cant_save_buddy_list()
299                 return
300         end
301
302         writeline("subj|"..CITADEL_BUDDY_MSG)
303         writeline("000")
304         m = nil
305         while true do
306                 local s = readline()
307                 if (s == "000") then
308                         break
309                 end
310                 if (not m) and (s ~= "000") then
311                         m = s
312                 end
313         end
314
315         if m then
316                 writeline("DELE "..m)
317                 m = get_response()
318                 if (m.response ~= CIT_OK) then
319                         cant_save_buddy_list()
320                         return
321                 end
322         end
323
324         -- Save our buddy list.
325         
326         writeline("ENT0 1||0|1|"..CITADEL_BUDDY_MSG.."|")
327         m = get_response()
328         if (m.response ~= SEND_LISTING) then
329                 cant_save_buddy_list()
330                 return
331         end
332
333         for name, _ in pairs(buddies) do
334                 local b = gaim_find_buddy(ga, name)
335                 if b then
336                         local alias = gaim_buddy_get_alias(b) or ""
337                         local group = gaim_find_buddys_group(b)
338                         local groupname = gaim_group_get_name(group)
339                         writeline(name.."|"..alias.."|"..groupname)
340                 end
341         end
342         writeline("000")
343
344         -- Go back to the lobby.
345         
346         writeline("GOTO _BASEROOM_")
347         get_response()
348 end
349
350 local function update_buddy_status()
351         writeline("RWHO")
352         local m = get_response()
353         if (m.response ~= LISTING_FOLLOWS) then
354                 return
355         end
356         log("attempting to scan and update buddies")
357
358         local onlinebuddies = {}
359         for _, s in ipairs(m.xargs) do
360                 local name = unpack_citadel_data_line(s)[2]
361                 onlinebuddies[name] = true
362         end
363
364         for s, _ in pairs(onlinebuddies) do
365                 serv_got_update(gc, s, true, 0, 0, 0, 0)
366         end
367 end
368
369 -----------------------------------------------------------------------------
370 --                               ENTRYPOINTS                               --
371 -----------------------------------------------------------------------------
372
373 function citadel_schedule_now()
374         schedule_now()
375 end
376
377 function citadel_input()
378         -- If there's no task, create one to handle this input.
379         
380         if not taskqueue[1] then
381                 queue(idle)
382         end
383 end
384
385 function citadel_setfd(_fd)
386         fd = _fd
387         log("fd = ", tonumber(fd))
388 end
389
390 function citadel_setgsc(_gsc)
391         gsc = tolua.cast(_gsc, "GaimSslConnection")
392         log("gsc registered")
393 end
394
395 function citadel_connect(_ga)
396         ga = tolua.cast(_ga, "GaimAccount")
397         gc = gaim_account_get_connection(ga)
398         
399         queue(function()
400                 local STEPS = 13
401
402                 username = gaim_account_get_username(ga)
403                 _, _, username, servername = string.find(username, "^(.*)@(.*)$")
404                 port = gaim_account_get_int(ga, "port", CITADEL_DEFAULT_PORT);
405                 
406                 log("connect to ", username, " on server ", servername, " port ", port)
407                 
408                 -- Make connection.
409                 
410                 gaim_connection_update_progress(gc, "Connecting", 1, STEPS)
411                 local i = interface_connect(ga, gc, servername, port)
412                 if (i ~= 0) then
413                         error("Unable to create socket")
414                 end
415                 
416                 local m = get_response()
417                 if (m.response ~= CIT_OK) then
418                         error("Unexpected response from server")
419                 end
420                 
421                 -- Switch to TLS mode, if desired.
422                 
423                 if gaim_account_get_bool(ga, "use_tls", true) then
424                         gaim_connection_update_progress(gc, "Requesting TLS", 2, STEPS)
425                         writeline("STLS")
426                         m = get_response()
427                         if (m.response ~= 200) then
428                                 error("This Citadel server does not support TLS.")
429                         end
430
431                         -- This will always work. If the handshake fails, Lua will be
432                         -- shot and we don't need to worry about cleaning up.
433                                                 
434                         gaim_connection_update_progress(gc, "TLS handshake", 3, STEPS)
435                         interface_tlson(gc, ga, fd)
436
437                         -- Wait for the gsc to be hooked up.
438
439                         while not gsc do
440                                 yield()
441                         end
442                 end
443                 
444                 -- Send username.
445                 
446                 gaim_connection_update_progress(gc, "Sending username", 4, STEPS)
447                 writeline("USER "..username)
448                 m = get_response()
449                 if (m.response == (ERROR+NO_SUCH_USER)) then
450                         error("There is no user with name '", username, "' on this server.")
451                 end
452                 if (m.response ~= MORE_DATA) then
453                         unexpectederror()
454                 end
455                 
456                 -- Send password.
457                 
458                 gaim_connection_update_progress(gc, "Sending password", 5, STEPS)
459                 writeline("PASS "..gaim_account_get_password(ga))
460                 m = get_response()
461                 if (m.response ~= CIT_OK) then
462                         error("Incorrect password.")
463                 end
464                 
465                 -- Tell Citadel who we are.
466                 
467                 gaim_connection_update_progress(gc, "Setting up", 6, STEPS)
468                 writeline("IDEN 226|0|0.2|Gaim Citadel plugin|")
469                 m = get_response()
470                 
471                 -- Set asynchronous mode.
472                 
473                 gaim_connection_update_progress(gc, "Setting up", 7, STEPS)
474                 writeline("ASYN 1")
475                 m = get_response()
476                 if (m.response ~= CIT_OK) then
477                         error("This Citadel server does not support instant messaging.")
478                 end
479                 
480                 (function()
481                         -- Switch to private configuration room.
482
483                         gaim_connection_update_progress(gc, "Setting up", 8, STEPS)
484                         writeline("GOTO "..CITADEL_CONFIG_ROOM)
485                         m = get_response()
486                         if (m.response ~= CIT_OK) then
487                                 warning("Unable to fetch buddy list from server.")
488                                 return
489                         end
490
491                         -- Look for our preferences.
492
493                         gaim_connection_update_progress(gc, "Setting up", 9, STEPS)
494                         writeline("MSGS ALL|0|1")
495                         m = get_response()
496                         if (m.response ~= START_CHAT_MODE) then
497                                 warning("Unable to fetch buddy list from server.")
498                                 return
499                         end
500
501                         writeline("subj|"..CITADEL_BUDDY_MSG)
502                         writeline("000")
503                         m = nil
504                         while true do
505                                 local s = readline()
506                                 if (s == "000") then
507                                         break
508                                 end
509                                 if (not m) and (s ~= "000") then
510                                         m = s
511                                 end
512                         end
513
514                         log("preference message in #", m)
515                         if not m then
516                                 return
517                         end
518                         
519                         gaim_connection_update_progress(gc, "Setting up", 10, STEPS)
520                         writeline("MSG0 "..m)
521                         while true do
522                                 local s = readline()
523                                 if (s == "000") then
524                                         return
525                                 end
526                                 if (s == "text") then
527                                         break
528                                 end
529                         end
530                         while true do
531                                 local s = readline()
532                                 if (s == "000") then
533                                         break
534                                 end
535                                 
536                                 local name, alias, groupname = unpack(unpack_citadel_data_line(s))
537                                 if not gaim_find_buddy(ga, name) then
538                                         local buddy = gaim_buddy_new(ga, name, alias)
539                                         local group = gaim_group_new(groupname)
540                                         log("adding new buddy ", name)
541                                         if buddy then
542                                                 -- buddy is not garbage collected! This must succeed!
543                                                 gaim_blist_add_buddy(buddy, nil, group, nil)
544                                         end
545                                 end
546                         end
547                 end)()
548
549                 -- Update buddy list with who's online.
550
551                 gaim_connection_update_progress(gc, "Setting up", 11, STEPS)
552                 update_buddy_status()
553
554                 -- Go back to the Lobby.
555
556                 gaim_connection_update_progress(gc, "Setting up", 12, STEPS)
557                 writeline("GOTO _BASEROOM_")
558                 get_response()
559
560                 -- Switch on the timer.
561                 
562                 timerhandle = interface_timeron(gc, 
563                         gaim_account_get_int(ga, "interval", CITADEL_POLL_INTERVAL)*1000)
564                         
565                 -- Done!
566                 
567                 gaim_connection_update_progress(gc, "Connected", 13, STEPS)
568                 gaim_connection_set_state(gc, GAIM_CONNECTED)
569         end)
570 end
571
572 function citadel_close()
573         interface_disconnect(fd or -1, gsc)
574         if timerhandle then
575                 interface_timeroff(gc, timerhandle)
576         end
577         schedule_now = function() end
578 end
579
580 function citadel_send_im(who, what, flags)
581         queue(function()
582                 writeline("SEXP ", who, "|-")
583                 local m = get_response()
584                 if (m.response ~= SEND_LISTING) then
585                         serv_got_im(gc, "Citadel", "Unable to send message", GAIM_MESSAGE_ERROR, 0);
586                         return
587                 end
588                 writeline(what)
589                 writeline("000")
590         end)
591 end
592
593 function citadel_fetch_pending_messages()
594         queue(function()
595                 while true do
596                         writeline("GEXP")
597                         local m = get_response()
598                         if (m.response ~= LISTING_FOLLOWS) then
599                                 break
600                         end
601
602                         local s = table.concat(m.xargs)
603                         --log("got message from ", m[4], " at ", m[2], ": ", s)
604                         serv_got_im(gc, m[4], s, GAIM_MESSAGE_RECV, m[2])
605                 end
606         end)
607 end
608
609 function citadel_get_info(name)
610         queue(function()
611                 writeline("RBIO "..name)
612                 local m = get_response()
613                 if (m.response ~= LISTING_FOLLOWS) then
614                         m = "That user has been boojumed."
615                 else
616                         m = table.concat(m.xargs, "<br>")
617                 end
618
619                 gaim_notify_userinfo(gc, name, name.."'s biography",
620                         name, "Biography", m, nil, nil)
621         end)
622 end
623
624 function citadel_keepalive()
625         queue(function()
626                 writeline("NOOP")
627                 get_response()
628         end)
629 end
630
631 -----------------------------------------------------------------------------
632 --                                BUDDY LIST                               --
633 -----------------------------------------------------------------------------
634
635 function citadel_add_buddy(name)
636         if not buddies[name] then
637                 buddies[name] = true
638                 lazyqueue(update_buddy_status)
639                 lazyqueue(save_buddy_list)
640         end
641 end
642
643 function citadel_remove_buddy(name)
644         if buddies[name] then
645                 buddies[name] = nil
646                 lazyqueue(save_buddy_list)
647         end
648 end
649
650 function citadel_alias_buddy(name)
651         if buddies[name] then
652                 lazyqueue(save_buddy_list)
653         end
654 end
655
656 function citadel_group_buddy(name, oldgroup, newgroup)
657         if buddies[name] then
658                 lazyqueue(save_buddy_list)
659         end
660 end
661
662 function citadel_timer()
663         log("tick!")
664         lazyqueue(update_buddy_status)
665 end
666
667 -----------------------------------------------------------------------------
668 --                                   IDLE                                  --
669 -----------------------------------------------------------------------------
670
671 idle = function()
672         queue(function()
673                 local m = get_response()
674                 if (m.response == (ASYNC_MSG+ASYNC_GEXP)) then
675                         citadel_fetch_pending_messages()
676                 end
677         end)
678 end