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