load 'web/gethttp' NB. ====== config ====== ircPort =: 6661 NB. local port the server will run on and you will connect to (normally 6667 for irc) oauthCallbackPort =: 8667 NB. port to run the callback on for oauth authentication debugEnable =: 1 NB. print optional debug information to the console when running (will contain secrets!) ioEnable =: 1 NB. show the raw irc lines sent and received NB. xxxxxx config xxxxxx NB. Define line endings for HTTP and IRC protocol CR =. 13{a. LF =. 10{a. CRLF =. CR,LF debug =: 3 : 0 if. debugEnable do. echo '[DEBUG] : ', ": y end. : if. debugEnable do. echo '[DEBUG] : ', ":y, ' : ', ": x end. ) NB. ====== apic api client library functions ====== token_apic_ =: 'none' useBearer =: 3 : 0 token_apic_ =: 'Bearer ', y '' ) apicMethod =: 3 : 0 'method url' =. y method debug 'apicMethod : method' url debug 'apicMethod : url' request =. '--request ', ": method, ' ' ('-s ', request, '--header ''authorization: ', token_apic_, '''') debug 'apicMethod : arguments' ('-s ', request, '--header ''authorization: ', token_apic_, '''') gethttp url : 'method url' =. y method debug 'apicMethod POST : method' url debug 'apicMethod POST : url' x debug 'apicMethod POST : body' request =. '--request ', ": method, ' ' if. 'none' -: token_apic_ do. auth =. '' else. auth =. '--header ''Authorization: ', token_apic_, ''' ' end. request =. '--request ', ": method, ' ' headers =. '--header ''Accept: application/json'' --header ''Content-Type: application/json'' ' data =. '--data ''', x, '''' ('-s ', auth, request, headers, data) debug 'apicMethod POST : full curl command' ('-s ', auth, request, headers, data) gethttp url ) apic =: 3 : 0 y debug 'apic : GET : url' apicMethod 'GET';y : y debug 'apic : POST : url' x debug 'apic : POST : body' x apicMethod 'POST';y ) NB. ====== json encoding and decoding library functions ====== cocurrent 'ejson' select =: 4 : 0 if. 0 = # $ y do. NB. you're at a list NB. TODO list of only 1 element if. 1 = #$>y do. deboxed =. (> "0 (>y)) if. 0 = #$x do. query =. x else. query =. y else. data =. y end. if. 0 = #$x do. key =. x else. key =. '&(8!:2)) fmtnums=: ' ' -.~ }.@,@(',' ,. >@{.@('d'&(8!:1))@,.) fmtint=: ,@('d0'&(8!:2)) fmtints=: ' ' -.~ }.@,@(',' ,. >@{.@('d0'&(8!:1))@,.) sep=: }.@;@:(','&,each) bc=: '{' , '}' ,~ ] bk=: '[' , ']' ,~ ] fixchar=: ] ESC=: _2[\('\';'\\';CR;'\r';LF;'\n';TAB;'\t';(8{a.);'\b';FF;'\f';'"';'\"';'''';'''\''''') encesc=: rplc&ESC remq=: ]`(}.@}:)@.('"' = {.) quotes=: '"'&,@(,&'"') false =: 'json_fls' falsed =: 0 true =: 'json_tru' trued =: 1 NULL =: 'json_nul' box =: <0$0 ESS=: a: nos=: 4 : 0 s=. x I.@E. y i=. s I. s+#x (i.#y) e. (s,_1) {~ (i,_1) {~^:a: 0 ) tc=: ,&_1 {~^:a: 0: nos1=: i.@#@] e. #@[ (tc@(]I.+) { _1,~]) I.@E. cutcommas=: 3 : 0 y=. ',',y m=. ~:/\ ('"' = y) > (_2 (|.!.0) '\\' nos y) < (_1 (|.!.0) '\"' E. y) m=. *./ (m < y=','), 0 = _2 +/\ @ (-/)\ m <"1 '{}[]'=/y m <@dltb;._1 y ) NB. ------ decoding ------ dec=: 3 : 0 dec1 dltb ' ' (y I.@:e. TAB,CRLF)} y ) dec1=: 3 : 0 if. 0=#y do. '' return. end. if. y-:'null' do. NULL return. end. select. {. y case. ESS do. ". }. y case. '{' do. dec_object y case. '[' do. dec_array y case. '"' do. decesc }.}:y case. do. dec_num y end. ) dec_object=: 3 : 0 if. 2=#y do. box return. end. dec_object1 &> a: -.~ cutcommas }.}:y ) dec_array=: 3 : 0 y=. dltb }.}:y if. 0=#y do. $0 return. end. if. -. y +./@:e. '"{[' do. dec_num y return. end. dec1 each cutcommas y ) decesc=: rplc&(1|."1 ESC) dec_num=: 3 : 0 nms=. ;: 'false true null' names =. false; true; 'null' res=. 0 ". ' ' (I.y=',')} y if. -. 1 e. ,nms E.& > res end. ) dec_object1=: 3 : 0 n=. 1 i.~ (y=':') > ~:/\ ('"'= y) > (_2 (|.!.0) '\\' nos y) < (_1 (|.!.0) '\"' E. y) k=. decesc remq dltb n {. y v=. dec1 dltb (n+1) }. y k;(true; false; NULL) ) enc_special =: 3 : 0 if. y -: true do. 'true' elseif. y -: false do. 'false' else. 'null' end. ) enc_char=: quotes @ encesc @ fixchar enc_num=: bk @ fmtnums`fmtnum @. isscalar enc_int=: bk @ fmtints`fmtint @. isscalar enc_dict=: 3 : 0 'rank>2 argument not supported' assert 2 = #$y 'rank 2 argument must be a dictionary' assert (2 = {:$y) > 0 e. ischar &> {."1 y key=. (enc_char each ({."1 y)) , each <':' val=. enc each {:"1 y rep=. ;key,.val ,each <',' bc (_1}.rep) ) finalize_ejson_^:(3=(4!:0)@<) 'finalize_ejson_' NB. finalize_ejson_^:(3=(4!:0)@<) 'finalize_ejson_' NB. finalize_ejson_ NB. finalize_ejson_^:(3=(4!:0)@<) 'finalize_ejson_' NB. finalize_ejson_ NB. finalize_ejson_^:(3=(4!:0)@<) 'finalize_ejson_' NB. finalize_ejson_ cocurrent 'base' NB. ====== webexjrcd code ====== batchedPersonInfoHelper =: 3 : 0 NB. filters the output > 'items' select_ejson_ dec_ejson_ apic webexapi 'people?id=', y ) batchedPersonInfo =: 3 : 0 maxBatched =: 85 members =: 1{ "_1 membersDetail y rows =: >. ((#members) % maxBatched) groups =: '%2C'&joinstring "_1 (rows, maxBatched)$!.'' members < , batchedPersonInfoHelper "_1 groups ) NB. webexWho batchedPersonInfo askCI NB. updateAllRooms'' webexapi =: 3 : 0 'https://webexapis.com/v1/', y ) NB. Strip Unicode escape sequences like \uD83D from string NB. Usage: stripUnicodeEscapes 'text\uD83Dmore' stripUnicodeEscapes =: 3 : 0 s =. y while. +./ '\u' E. s do. NB. Find first occurrence of \u mask =. '\u' E. s idx =. mask i. 1 NB. Remove \u and next 4 characters (6 total) if. (idx + 6) <: # s do. s =. (idx {. s) , ((idx + 6) }. s) else. NB. Not enough characters left, just remove to end s =. idx {. s end. end. s ) NB. Clean channel name for IRC NB. Usage: ircCleanChan 'Room Name' or roomId ircCleanChan 'Room Name' NB. y: character string - raw room/channel name NB. x: (optional) character string - room ID for smart truncation NB. Returns: character string - sanitized channel name with '#' prefix NB. Example: ircCleanChan 'My Room' -> '#My_Room' NB. Converts spaces to underscores, removes Unicode escapes and invalid chars NB. If > 64 chars, truncates to 60 and appends :abc (last 3 chars of room ID) ircCleanChan =: 3 : 0 NB. Monadic form - no room ID provided NB. First strip Unicode escape sequences like \uD83D name =. stripUnicodeEscapes y name =. name rplc ' ';'_' name =. name rplc ':';';' name =. name -. ',',CR,LF,(0 7{a.) if. '#' ~: {. name do. name =. '#',name end. NB. Simple cap at 64 characters if. 64 < # name do. name =. 64 {. name end. name : NB. Dyadic form - room ID provided for smart truncation roomId =. x NB. First strip Unicode escape sequences like \uD83D name =. stripUnicodeEscapes y name =. name rplc ' ';'_' name =. name rplc ':';';' name =. name -. ',',CR,LF,(0 7{a.) if. '#' ~: {. name do. name =. '#',name end. NB. Cap at 64 characters with smart suffix if. 64 < # name do. NB. Get last 3 characters of room ID suffix =. _3 {. roomId NB. Truncate to 60 chars and append ;suffix name =. (60 {. name), ';', suffix end. name ) touchTime =: '' rooms =: '' NB. Connected clients tracking NB. Format: (socket ; nick ; channels) NB. channels is boxed list of channel names clients =: 0 3 $ a: NB. Add or update client in global clients list NB. Usage: sock clientAdd nick clientAdd =: 4 : 0 NB. Remove existing entry for this socket if present if. 0 < #clients do. mask =. +/ |: > ( ( 0 { row nick =. > 1 { row chans =. 2 { row NB. chans is a box containing a list - add new channel if. 32 = 3!:0 chans do. chanList =. > chans if. 32 = 3!:0 chanList do. newChanList =. chanList , ( 0 { row nick =. > 1 { row chans =. 2 { row NB. Remove channel from list if. 32 = 3!:0 chans do. chanList =. > chans if. 32 = 3!:0 chanList do. NB. Remove the parting channel using set difference NB. chanList is a list of boxes, y needs to be boxed for removal newChanList =. chanList -. ( 1 { row title =. > 2 { row title debug 'updateAllRooms : original title' clean =. roomId ircCleanChan title clean debug 'updateAllRooms : cleaned title' cleanTitle =. cleanTitle , 0{ {. rooms 'Update complete' debug 'updateAllRooms' }} updateRooms =: {{ NB. Fetch most recent 10 rooms temp =. ('lastActivity';'id';'title') selector_ejson_ 'items' select_ejson_ dec_ejson_ apic webexapi 'rooms?max=10&sortBy=lastactivity' NB. Extract columns origTitle =. ,. 2} "_1 temp roomIds =. 1} "_1 temp NB. Apply ircCleanChan with room ID for smart truncation cleanTitle =. 0$a: for_i. i. # temp do. row =. i { temp roomId =. > 1 { row title =. > 2 { row clean =. roomId ircCleanChan title cleanTitle =. cleanTitle , 1 { newRoom NB. Find if this room exists in current rooms list roomExists =. 0 if. 0 < # rooms do. NB. Extract room IDs from existing rooms (column 1) existingIds =. 1 {"1 rooms NB. Check if newId exists in the list roomMatch =. > ( 0{ {. newRooms touchTime =: roomTime end. NB. Return touchTime and list of added room IDs touchTime;addedRoomIds }} NB. updateAllRooms now called after authentication, not at startup trimEmailNick =: 3 : 0 > {. '@' cut > y ) roomIdToChannel =: 3 : 0 NB. Find room by ID (column 1) and return channel name (column 2) y debug 'roomIdToChannel : looking for roomId' mask =. +/ "_1 > ( result) debug 'roomIdToChannel : found channel' end. result ) NB. Get room topic from local data (original title :: roomId) NB. Usage: getRoomTopic channel or getRoomTopic roomId NB. Returns: character string - 'Original Room Title :: roomId' getRoomTopic =: 3 : 0 NB. Check if y is a channel name or room ID if. '#' = {. y do. NB. It's a channel name - find by channel if. 0 < # rooms do. mask =. +/ "_1 > ( 3 { row roomId =. > 1 { row origTitle, ' :: ', roomId else. 'Channel not found' end. else. 'No rooms loaded' end. else. NB. It's a room ID - find by ID if. 0 < # rooms do. mask =. +/ "_1 > ( 3 { row origTitle, ' :: ', y else. 'Room not found' end. else. 'No rooms loaded' end. end. ) NB. roomIdToChannel bapple messages =: 3 : 0 response =. apic webexapi 'messages?roomId=', y decoded =. dec_ejson_ response items =. 'items' select_ejson_ decoded temp =. ('created';'text';'id';'personEmail') selector_ejson_ items nick =. ,. <@trimEmailNick@> 3} "_1 temp rest =. }: "_1 temp NB. Get parentId separately, defaulting to empty string if not present NB. items is a boxed list, we need to extract each message's parentId parentIds =. 0$<'' NB. Need to get the actual array of items itemArray =. > items NB. Process each item if. 0 < # itemArray do. for_i. i. # itemArray do. try. item =. i { itemArray pid =. 'parentId' select_ejson_ item parentIds =. parentIds , 3} "_1 temp rest =. }: "_1 temp NB. Get parentId separately, defaulting to empty string if not present NB. items is a boxed list, we need to extract each message's parentId parentIds =. 0$<'' NB. Need to get the actual array of items itemArray =. > items NB. Process each item if. 0 < # itemArray do. for_i. i. # itemArray do. try. item =. i { itemArray pid =. 'parentId' select_ejson_ item parentIds =. parentIds , userName myNick debug 'getMyNick : myNick' myName =: 'nickName' select_ejson_ decoded myName debug 'getMyNick : myName' catch. myNick =: '' end. myNick ) NB. getMyNick now called after authentication via PASS command NB. Buffer to track messages sent from IRC to prevent echo NB. Format: list of boxed text strings sentMessagesBuffer =: 0$a: NB. Track last message ID per channel per user for editing NB. Format: list of boxes, each containing (channel ; nick ; messageId ; messageText) lastMessages =: 0$a: NB. Track message IDs by suffix for replies NB. Format: (suffix ; messageId ; roomId) messageSuffixes =: 0 3 $ a: webexSend =: 4 : 0 'roomId channel ircNick' =. y 'webexSend called' debug 'webexSend' x debug 'webexSend : message text (x)' roomId debug 'webexSend : roomId' channel debug 'webexSend : channel' ircNick debug 'webexSend : ircNick' body =. enc_ejson_ 2 2 $ 'roomId';roomId;'markdown';x body debug 'webexSend : JSON body' response =. body apic webexapi 'messages' 'API call completed' debug 'webexSend' NB. Extract message ID from response messageId =. > 'id' select_ejson_ dec_ejson_ response NB. Track message for editing as a box containing boxed items newMsg =. < channel ; ircNick ; messageId ; x NB. Remove any existing entry for this channel/nick if. 0 < # lastMessages do. NB. Find and remove matching channel/nick keep =. 1 for_i. i. # lastMessages do. items =. > i { lastMessages if. (channel -: > 0 { items) *. (ircNick -: > 1 { items) do. keep =. 0 end. end. if. keep do. lastMessages =: lastMessages , newMsg else. NB. Remove matching entry and append new mask =. 1 #~ # lastMessages for_i. i. # lastMessages do. items =. > i { lastMessages if. (channel -: > 0 { items) *. (ircNick -: > 1 { items) do. mask =. 0 i } mask end. end. lastMessages =: (mask # lastMessages) , newMsg end. else. lastMessages =: , newMsg end. ) NB. Send direct message to a person in Webex NB. Usage: text webexSendDirect personId;targetNick;ircNick webexSendDirect =: 4 : 0 'personId targetNick ircNick' =. y body =. enc_ejson_ 2 2 $ 'toPersonId';personId;'text';x response =. body apic webexapi 'messages' respData =. dec_ejson_ response NB. Extract message ID, timestamp, and roomId from response messageId =. > 'id' select_ejson_ respData timestamp =. > 'created' select_ejson_ respData roomId =. > 'roomId' select_ejson_ respData NB. Return messageId;timestamp;roomId for echoing and room tracking messageId;timestamp;roomId ) NB. Reply to a message in Webex NB. Usage: text webexReply parentId;roomId;channel;ircNick webexReply =: 4 : 0 'parentId roomId channel ircNick' =. y body =. enc_ejson_ 3 2 $ 'roomId';roomId;'parentId';parentId;'markdown';x response =. body apic webexapi 'messages' NB. Extract message ID from response messageId =. > 'id' select_ejson_ dec_ejson_ response NB. Track message for editing as a box containing boxed items newMsg =. < channel ; ircNick ; messageId ; x if. 0 < # lastMessages do. NB. Remove matching entry and append new mask =. 1 #~ # lastMessages for_i. i. # lastMessages do. items =. > i { lastMessages if. (channel -: > 0 { items) *. (ircNick -: > 1 { items) do. mask =. 0 i } mask end. end. lastMessages =: (mask # lastMessages) , newMsg else. lastMessages =: , newMsg end. ) NB. Update an existing message in Webex NB. Usage: newText webexUpdate messageId;roomId webexUpdate =: 4 : 0 'messageId roomId' =. y 'Updating message' debug 'webexUpdate' messageId debug 'webexUpdate : messageId' roomId debug 'webexUpdate : roomId' x debug 'webexUpdate : new text' body =. enc_ejson_ 2 2 $ 'roomId';roomId;'text';x body debug 'webexUpdate : JSON body' if. 'none' -: token_apic_ do. auth =. '' 'WARNING: No auth token' debug 'webexUpdate' else. auth =. '--header ''Authorization: ', token_apic_, ''' ' end. request =. '--request PUT ' headers =. '--header ''Accept: application/json'' --header ''Content-Type: application/json'' ' data =. '--data ''', body, '''' url =. webexapi 'messages/', messageId url debug 'webexUpdate : URL' cmd =. '-s ', auth, request, headers, data cmd debug 'webexUpdate : curl command' response =. cmd gethttp url 'Message updated' debug 'webexUpdate' response ) NB. Delete a message in Webex NB. Usage: webexDelete messageId webexDelete =: 3 : 0 messageId =. y 'Deleting message' debug 'webexDelete' messageId debug 'webexDelete : messageId' if. 'none' -: token_apic_ do. auth =. '' 'WARNING: No auth token' debug 'webexDelete' else. auth =. '--header ''Authorization: ', token_apic_, ''' ' end. request =. '--request DELETE ' url =. webexapi 'messages/', messageId url debug 'webexDelete : URL' cmd =. '-s ', auth, request cmd debug 'webexDelete : curl command' response =. cmd gethttp url 'Message deleted' debug 'webexDelete' response ) NB. ======================================== NB. OAuth AUTHENTICATION FUNCTIONS NB. ======================================== NB. Generate random string for OAuth state randomString =: 3 : 0 chars =. 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' chars {~ ? y # # chars ) NB. Start OAuth flow for clientId:clientSecret authentication NB. Returns: authUrl ; state ; oauthCallbackPort ; clientId ; clientSecret ; nick startOAuth =: 3 : 0 'clientId clientSecret sock nick' =. y 'Starting OAuth flow' debug 'startOAuth' clientId debug 'startOAuth : clientId' nick debug 'startOAuth : nick' NB. Generate state for security state =. randomString 32 state debug 'startOAuth : generated state' NB. Use port 8667 for callback (similar to IRC 6667 but unused) NB. port =. 8667 redirectUri =. 'http://localhost:',(": oauthCallbackPort),'/callback' redirectUri debug 'startOAuth : redirectUri' NB. Build authorization URL authUrl =. 'https://webexapis.com/v1/authorize' authUrl =. authUrl,'?client_id=',clientId authUrl =. authUrl,'&response_type=code' authUrl =. authUrl,'&redirect_uri=',redirectUri authUrl =. authUrl,'&scope=spark:all' authUrl =. authUrl,'&state=',state authUrl debug 'startOAuth : authUrl' authUrl ; state ; oauthCallbackPort ; clientId ; clientSecret ; nick ) NB. Start OAuth callback server NB. Usage: port startOAuthServer state;clientId;clientSecret;sock;nick NB. Returns: refreshToken or empty string on failure startOAuthServer =: 4 : 0 debug 'startOAuthServer' refreshToken =. '' NB. Initialize here so it's available in catch block try. 'state clientId clientSecret sock nick' =. y port =. x NB. Create server socket (matching ircserver's working pattern) coinsert 'jsocket' 'rc lsock' =. sdsocket 2 1 0 if. rc ~: 0 do. '' return. end. NB. Set socket options (matching ircserver) sdsetsockopt lsock ; SOL_SOCKET ; SO_REUSEADDR ; 1 NB. Bind to all interfaces rc =. sdbind lsock ; 2 ; '0.0.0.0' ; port rc debug 'startOAuthServer : rc' if. rc ~: 0 do. sdclose lsock '' return. end. rc =. sdlisten lsock , 5 if. rc ~: 0 do. sdclose lsock '' return. end. NB. Wait up to 55 seconds for OAuth callback (to avoid IRC client timeout) gotConnection =. 0 csock =. 0 startTime =. 6!:1 '' startTime debug 'startOAuthServer : startTime' timeoutValue =. 180 NB. 3 minutes timeout instead of 55 seconds condition =. (-.gotConnection) *. ((6!:1 '') - startTime) < timeoutValue condition debug 'startOAuthServer : initial condition check' ((6!:1 '') - startTime) debug 'startOAuthServer : initial elapsed check' while. (-.gotConnection) *. ((6!:1 '') - startTime) < timeoutValue do. currentTime =. 6!:1 '' elapsed =. currentTime - startTime currentTime debug 'startOAuthServer : currentTime' elapsed debug 'startOAuthServer : elapsed' NB. Use select with 2 second timeout to check for connections selectResult =. sdselect lsock ; '' ; '' ; 2 selectResult debug 'startOAuthServer : selectResult' if. 32 = 3!:0 selectResult do. NB. Result is boxed rc =. > 0 { selectResult if. 4 = # selectResult do. readSocks =. > 1 { selectResult else. readSocks =. '' end. else. NB. Direct error code rc =. selectResult readSocks =. '' end. if. rc = 0 do. NB. Check if our socket is ready (either as number or in list) ready =. 0 NB. Only check if readSocks is not empty if. (0 < # ": readSocks) do. if. lsock = readSocks do. ready =. 1 end. if. (32 = 3!:0 readSocks) *. (lsock e. readSocks) do. ready =. 1 end. end. ready debug 'startOAuthServer : ready flag' (": readSocks) debug 'startOAuthServer : readSocks' if. ready do. NB. Connection available - accept it 'rc csock' =. sdaccept lsock 'Accepting connection' debug 'startOAuthServer : attempting accept' if. rc = 0 do. gotConnection =. 1 'Connection accepted' debug 'startOAuthServer : success' else. ('Accept failed with rc=' , ": rc) debug 'startOAuthServer : error' end. end. end. NB. Send periodic updates to keep connection alive elapsed =. <. (6!:1 '') - startTime if. (-.gotConnection) *. (0 = 10 | elapsed) *. (elapsed > 0) do. try. remaining =. timeoutValue - elapsed ('Still waiting for OAuth callback... (', (": remaining), ' seconds remaining)') ircNotice nick;sock NB. Send PING to keep client alive (':webex.local PING :oauth-wait-', (": elapsed)) ircMsg sock catch. end. end. NB. Debug: Check loop condition before next iteration loopCondition =. (-.gotConnection) *. ((6!:1 '') - startTime) < timeoutValue loopCondition debug 'startOAuthServer : loop condition at end of iteration' gotConnection debug 'startOAuthServer : gotConnection at end' ((6!:1 '') - startTime) debug 'startOAuthServer : elapsed at end' end. NB. Debug: Why did we exit the loop? 'Loop exited' debug 'startOAuthServer : after while loop' gotConnection debug 'startOAuthServer : final gotConnection' ((6!:1 '') - startTime) debug 'startOAuthServer : final elapsed time' if. -. gotConnection do. 'No connection received' debug 'startOAuthServer : returning empty' sdclose lsock '' return. end. NB. Read HTTP request 'rc request' =. sdrecv csock , 4096 , 0 rc debug 'startOAuthServer : sdrecv rc' (100 {. request) debug 'startOAuthServer : request (first 100 chars)' NB. Parse query parameters code =. '' rstate =. '' if. 'GET /callback?' -: 14 {. request do. 'Parsing callback' debug 'startOAuthServer : found callback' NB. Extract from position 14 (after "GET /callback?") to next space remainder =. 14 }. request spacePos =. remainder i. ' ' if. spacePos < # remainder do. query =. spacePos {. remainder else. query =. remainder end. NB. Trim any whitespace and control characters query =. query -. CR,LF params =. <;._1 '&', '&', query for_param. params do. param =. > param if. 'code=' -: 5 {. param do. code =. 5 }. param elseif. 'state=' -: 6 {. param do. rstate =. 6 }. param end. end. else. 'Not a callback request' debug 'startOAuthServer : not callback' (14 {. request) debug 'startOAuthServer : first 14 chars of request' end. code debug 'startOAuthServer : parsed code' rstate debug 'startOAuthServer : parsed rstate' state debug 'startOAuthServer : expected state' NB. Send response to browser if. (0 < # code) *. (state -: rstate) do. 'State matches, exchanging code' debug 'startOAuthServer : proceeding' NB. Exchange code for tokens first to get refresh token refreshToken =. code exchangeCodeForTokens clientId;clientSecret;port refreshToken debug 'startOAuthServer : got refreshToken' response =. 'HTTP/1.1 200 OK',CRLF response =. response,'Content-Type: text/html; charset=utf-8',CRLF response =. response,'Connection: close',CRLF,CRLF response =. response,'webexjrcd - Authorization Successful' response =. response,'' response =. response,'

webexjrcd Authorization Successful!

' response =. response,'

Your Webex integration has been authorized

' response =. response,'

Perfect! Your OAuth authorization is complete and you''re ready to go.

' if. 0 < # refreshToken do. response =. response,'

Use this PASS command for future connections to avoid the oauth flow (valid for ~90 days):

' response =. response,'
PASS ',clientId,':',clientSecret,':',refreshToken,'
' NB. response =. response,'

Provide this as your IRC password when connecting to webexjrcd

' else. response =. response,'

Your refresh token has been sent to your IRC client, so you will be connected for this session

' end. response =. response,'
' response =. response,'

Next Steps

' response =. response,'

1. Copy the PASS text above
' response =. response,'2. Save it as the password future connections
' response =. response,'3. Return to your IRC client - authentication will complete automatically

' response =. response,'' response =. response,'' else. 'Code or state mismatch' debug 'startOAuthServer : auth failed' (": 0 < # code) debug 'startOAuthServer : has code?' (": state -: rstate) debug 'startOAuthServer : state matches?' response =. 'HTTP/1.1 400 Bad Request',CRLF response =. response,'Content-Type: text/html; charset=utf-8',CRLF response =. response,'Connection: close',CRLF,CRLF response =. response,'webexjrcd - Authorization Failed' response =. response,'' response =. response,'

webexjrcd Authorization Failed

' response =. response,'

The OAuth authorization could not be completed

' response =. response,'

Something went wrong. Possible reasons:

' response =. response,'
  • The authorization was cancelled
  • The session expired (3 minute timeout)
  • ' response =. response,'
  • The security token did not match
' response =. response,'

Please return to your IRC client and try connecting again with:
' response =. response,'PASS clientId:clientSecret

' response =. response,'' response =. response,'' end. try. 'rc sent' =. response sdsend csock , 0 ('Sent ' , ": sent) debug 'startOAuthServer : bytes sent to browser' catch. 'Failed to send response to browser' debug 'startOAuthServer : send error' end. NB. Clean up try. sdclose csock catch. end. try. sdclose lsock catch. end. NB. Return the refresh token we already got (or empty if failed) refreshToken debug 'startOAuthServer : returning refreshToken' refreshToken catch. errMsg =. 13!:12 '' NB. Get error message errMsg debug 'startOAuthServer : exception details' 'Exception in startOAuthServer' debug 'startOAuthServer : catch block' NB. If we already have a refresh token, return it despite the error if. 0 < # refreshToken do. 'Returning refreshToken despite exception' debug 'startOAuthServer : recovery' try. sdclose csock catch. end. try. sdclose lsock catch. end. refreshToken else. try. sdclose csock catch. end. try. sdclose lsock catch. end. '' end. end. ) NB. Exchange authorization code for tokens NB. Usage: code exchangeCodeForTokens clientId;clientSecret;port NB. Returns: refreshToken or empty string exchangeCodeForTokens =: 4 : 0 'clientId clientSecret port' =. y code =. x 'Exchanging code for tokens' debug 'exchangeCodeForTokens' code debug 'exchangeCodeForTokens : authorization code' clientId debug 'exchangeCodeForTokens : clientId' redirectUri =. 'http://localhost:',(": port),'/callback' redirectUri debug 'exchangeCodeForTokens : redirectUri' tokenUrl =. 'https://webexapis.com/v1/access_token' tokenUrl debug 'exchangeCodeForTokens : tokenUrl' NB. Build form data formData =. 'grant_type=authorization_code' formData =. formData,'&code=',code formData =. formData,'&client_id=',clientId formData =. formData,'&client_secret=',clientSecret formData =. formData,'&redirect_uri=',redirectUri 'Form data built' debug 'exchangeCodeForTokens' NB. Make POST request cmd =. '-s --request POST ' cmd =. cmd,'--header ''Content-Type: application/x-www-form-urlencoded'' ' cmd =. cmd,'--data ''',formData,''' ' cmd debug 'exchangeCodeForTokens : curl command' NB. TODO: bya: test if this works NB. response =. apicMethod 'POST';tokenUrl response =. cmd gethttp tokenUrl 'Token exchange response received' debug 'exchangeCodeForTokens' NB. Parse JSON response try. tokens =. dec_ejson_ response 'Tokens parsed successfully' debug 'exchangeCodeForTokens' accessToken =. > 'access_token' select_ejson_ tokens refreshToken =. > 'refresh_token' select_ejson_ tokens 'Access token extracted' debug 'exchangeCodeForTokens' 'Refresh token extracted' debug 'exchangeCodeForTokens' NB. Set the access token immediately useBearer accessToken 'Access token set' debug 'exchangeCodeForTokens' NB. Return refresh token for user to save refreshToken catch. 'ERROR: Failed to parse token response' debug 'exchangeCodeForTokens' response debug 'exchangeCodeForTokens : error response' '' end. ) NB. Refresh access token using refresh token NB. Usage: refreshAccessToken clientId;clientSecret;refreshToken NB. Returns: 1 if successful, 0 if failed refreshAccessToken =: 3 : 0 'clientId clientSecret refreshToken' =. y 'Refreshing access token' debug 'refreshAccessToken' clientId debug 'refreshAccessToken : clientId' tokenUrl =. 'https://webexapis.com/v1/access_token' tokenUrl debug 'refreshAccessToken : tokenUrl' NB. Build form data formData =. 'grant_type=refresh_token' formData =. formData,'&refresh_token=',refreshToken formData =. formData,'&client_id=',clientId formData =. formData,'&client_secret=',clientSecret 'Form data built' debug 'refreshAccessToken' NB. Make POST request cmd =. '-s --request POST ' cmd =. cmd,'--header ''Content-Type: application/x-www-form-urlencoded'' ' cmd =. cmd,'--data ''',formData,''' ' cmd debug 'refreshAccessToken : curl command' NB. TODO: bya: test if this works NB. response =. apicMethod 'POST';tokenUrl response =. cmd gethttp tokenUrl 'Token refresh response received' debug 'refreshAccessToken' NB. Parse JSON response try. tokens =. dec_ejson_ response 'Tokens parsed successfully' debug 'refreshAccessToken' accessToken =. > 'access_token' select_ejson_ tokens newRefreshToken =. > 'refresh_token' select_ejson_ tokens 'New access token extracted' debug 'refreshAccessToken' NB. Set the new access token useBearer accessToken 'Access token updated' debug 'refreshAccessToken' NB. Return new refresh token if different if. -. refreshToken -: newRefreshToken do. 'New refresh token received' debug 'refreshAccessToken' newRefreshToken else. 'Same refresh token retained' debug 'refreshAccessToken' '1' NB. Success but same refresh token end. catch. 'ERROR: Failed to refresh token' debug 'refreshAccessToken' response debug 'refreshAccessToken : error response' '0' NB. Failed end. ) NB. Get room title from Webex NB. Usage: roomTitle roomId NB. Returns: character string - room title roomTitle =: 3 : 0 response =. apic webexapi 'rooms/', y > 'title' select_ejson_ dec_ejson_ response ) newerDate =: 4 : 0 if. x -: y do. 0 else. }. /: x;y end. ) NB. Original non-paginated versions (kept for reference) NB. members =: 3 : 0 NB. <@trimEmailNick@> "_1 'personEmail' select_ejson_ 'items' select_ejson_ dec_ejson_ apic webexapi 'memberships?max=1000&roomId=', y NB. ) NB. membersDetail =: 3 : 0 NB. temp =. ('personEmail';'personId') selector_ejson_ 'items' select_ejson_ dec_ejson_ apic webexapi 'memberships?max=1000&roomId=', y NB. nicks =. <@trimEmailNick@> 0} "_1 temp NB. rest =. 1} "_1 temp NB. nicks,.rest NB. ) NB. Updated functions that use pagination members =: 3 : 0 membersPaginatedSimple y ) membersDetail =: 3 : 0 membersPaginated y ) NB. Fetch data with headers included (for pagination support) NB. Returns: headers;body (2-element boxed list) apicWithHeaders =: 3 : 0 url =. y url debug 'apicWithHeaders : url' NB. Build curl command with -i to include headers, following apicMethod pattern if. 'none' -: token_apic_ do. auth =. '' else. auth =. '--header ''Authorization: ', token_apic_, ''' ' end. NB. Use -i to include headers in response, -s for silent cmd =. '-s -i --request GET ', auth cmd debug 'apicWithHeaders : curl options' NB. Use gethttp like the rest of the code response =. cmd gethttp url (": # response) debug 'apicWithHeaders : response length' ((100 <. # response) {. response) debug 'apicWithHeaders : first 100 chars' NB. Split headers and body at double CRLF crlf2 =. CRLF,CRLF NB. Find all positions where pattern occurs and take the first positions =. I. crlf2 E. response (": # positions) debug 'apicWithHeaders : CRLF,CRLF matches found' if. 0 < # positions do. split =. 0 { positions (": split) debug 'apicWithHeaders : CRLF,CRLF split position' headers =. split {. response body =. ((split + # crlf2) }. response) (": # headers) debug 'apicWithHeaders : headers length' (": # body) debug 'apicWithHeaders : body length' result =. headers;body else. NB. Try with just LF,LF in case the server uses Unix line endings lf2 =. LF,LF positions2 =. I. lf2 E. response (": # positions2) debug 'apicWithHeaders : LF,LF matches found' if. 0 < # positions2 do. split2 =. 0 { positions2 (": split2) debug 'apicWithHeaders : LF,LF split position' headers =. split2 {. response body =. ((split2 + # lf2) }. response) 'Found with LF,LF instead of CRLF,CRLF' debug 'apicWithHeaders : note' result =. headers;body else. 'No header separator found, returning as body only' debug 'apicWithHeaders : warning' result =. '';response end. end. (": # result) debug 'apicWithHeaders : result count' result ) NB. Parse Link header for next URL parseLinkHeader =: 3 : 0 headers =. y (": # headers) debug 'parseLinkHeader : headers length' NB. Debug: show last 300 chars of headers to see if Link is there ((_300 >. -# headers) {. headers) debug 'parseLinkHeader : last 300 chars of headers' NB. Normalize line endings and split headers =. headers rplc CRLF;LF lines =. <;._2 headers,LF (": # lines) debug 'parseLinkHeader : number of header lines' linkLine =. '' foundLink =. 0 for_line. lines do. line =. > line NB. Remove any trailing CR or LF line =. line -. CR,LF NB. Debug: check for 'link' in the line (case-sensitive for now) if. (+./ 'Link' E. line) +. (+./ 'link' E. line) do. ((100 <. # line) {. line) debug 'parseLinkHeader : potential Link line (first 100 chars)' end. NB. Check for link header case-insensitively if. 'link:' -: tolower 5 {. line do. NB. Extract everything after 'link:' and trim spaces linkLine =. dltb 5 }. line 'Found Link header' debug 'parseLinkHeader : status' foundLink =. 1 break. end. end. if. -. foundLink do. 'No Link header found' debug 'parseLinkHeader : status' '' return. end. ((200 <. # linkLine) {. linkLine) debug 'parseLinkHeader : Link header content (first 200 chars)' NB. Look for rel="next" if. +./ 'rel="next"' E. linkLine do. 'Found rel="next"' debug 'parseLinkHeader : status' NB. Extract URL between < and > start =. linkLine i. '<' end =. linkLine i. '>' if. (start < end) *. (end < # linkLine) do. nextUrl =. (>:start) }. (end {. linkLine) NB. Clean any whitespace nextUrl =. dltb nextUrl nextUrl debug 'parseLinkHeader : extracted next URL' nextUrl else. 'Could not extract URL from Link header' debug 'parseLinkHeader : error' '' end. else. 'No rel="next" in Link header' debug 'parseLinkHeader : status' '' end. ) NB. Members with pagination support membersPaginated =: 3 : 0 roomId =. y allItems =. 0 2 $ a: url =. webexapi 'memberships?max=100&roomId=', roomId pageCount =. 0 roomId debug 'membersPaginated : starting pagination for room' while. 0 < # url do. pageCount =. pageCount + 1 ((": pageCount), ' from ', (50 {. url)) debug 'membersPaginated : fetching page' result =. apicWithHeaders url (": # result) debug 'membersPaginated : apicWithHeaders result count' NB. Check if we got the expected 2-element result if. 2 ~: # result do. 'Invalid response from apicWithHeaders' debug 'membersPaginated : error' url =. '' continue. end. 'headers body' =. result NB. Check for HTTP errors if. +./ 'HTTP/2 401' E. headers do. 'Authentication failed (401 Unauthorized)' debug 'membersPaginated : error' echo 'ERROR: Bearer token expired or invalid. Please re-authenticate.' url =. '' continue. elseif. +./ 'HTTP/2 403' E. headers do. 'Access forbidden (403)' debug 'membersPaginated : error' url =. '' continue. elseif. +./ 'HTTP/2 404' E. headers do. 'Room not found (404)' debug 'membersPaginated : error' url =. '' continue. end. NB. Parse current page items try. items =. ('personEmail';'personId') selector_ejson_ 'items' select_ejson_ dec_ejson_ body if. 0 < # items do. ((": # items), ' items on page ', ": pageCount) debug 'membersPaginated : found' allItems =. allItems , items else. 'No items in response' debug 'membersPaginated : note' ((50 <. # body) {. body) debug 'membersPaginated : body preview' end. catch. NB. Error parsing this page, stop pagination ((": pageCount), ' - stopping') debug 'membersPaginated : error parsing page' ((50 <. # body) {. body) debug 'membersPaginated : error response' url =. '' continue. end. NB. Get next page URL nextUrl =. parseLinkHeader headers if. 0 < # nextUrl do. 'found' debug 'membersPaginated : next page URL' if. nextUrl -: url do. NB. Prevent infinite loop if same URL returned 'stopping to prevent loop' debug 'membersPaginated : same URL returned' url =. '' else. url =. nextUrl end. else. 'finishing' debug 'membersPaginated : no next page' url =. '' end. NB. Safety limit - max 100 pages if. pageCount > 100 do. echo 'Warning: membersPaginated hit 100 page limit for room ', roomId url =. '' end. end. (": # allItems) debug 'membersPaginated : total items collected' NB. Process all results if. 0 = # allItems do. 0 2 $ a: else. nicks =. <@trimEmailNick@> 0} "_1 allItems rest =. 1} "_1 allItems nicks,.rest end. ) NB. Simple members function with pagination membersPaginatedSimple =: 3 : 0 details =. membersPaginated y if. 0 < # details do. 0} "_1 details NB. Just return the nicks column else. 0 $ a: end. ) chanToRoomId =: 3 : 0 NB. Find room by channel name (column 2) and return room ID (column 1) y debug 'chanToRoomId : looking for channel' mask =. +/ "_1 > ( result) debug 'chanToRoomId : found roomId' end. result ) newMessages =: 4 : 0 msgs =. messages y mask =. , x&newerDate "_1 > 0} "_1 msgs filteredMsgs =. mask # msgs channel =. > roomIdToChannel y NB. Append channel as a new column - replicate it for each row channelCol =. (#filteredMsgs) $ 0} "_1 rooms updates =: 1} "_1 mask # rooms if. 0 = # updates do. sentMessagesBuffer =: 0$a: (0 6 $ a:);addedRoomIds else. NB. Get messages for each room, combine vertically result =. ,/ touch&newMessages each updates echo 'DEBUG allNewMessages: raw result shape = ', ": $ result echo 'DEBUG allNewMessages: raw result type = ', ": 3!:0 result NB. Ensure result is unboxed 2D array, not a scalar box if. 0 = #$ result do. result =. > result echo 'DEBUG allNewMessages: unboxed result' end. echo 'DEBUG allNewMessages: result shape after unbox = ', ": $ result NB. Filter out messages from sentMessagesBuffer that match myNick NB. DISABLED: We want to see our own messages with the [suffix] prefix NB. if. (0 < # result) *. (0 < # sentMessagesBuffer) do. NB. NB. result format: (timestamp ; text ; id ; sender ; channel) NB. keepMask =. 1 $ ~ # result NB. for_i. i. # result do. NB. row =. i { result NB. msgSender =. > 3 { row NB. msgText =. > 1 { row NB. if. (msgSender -: myNick) *. (( 2 { row msgSender =. > 3 { row msgChannel =. > 5 { row msgText =. > 1 { row NB. Update or add this message as a box containing boxed items newMsg =. < msgChannel ; msgSender ; msgId ; msgText if. 0 < # lastMessages do. NB. Remove matching entry and append new mask =. 1 #~ # lastMessages for_j. i. # lastMessages do. items =. > j { lastMessages if. (msgChannel -: > 0 { items) *. (msgSender -: > 1 { items) do. mask =. 0 j } mask end. end. lastMessages =: (mask # lastMessages) , newMsg else. lastMessages =: , newMsg end. end. end. NB. Always clear buffer at end sentMessagesBuffer =: 0$a: echo 'DEBUG allNewMessages: returning ', (": # result), ' messages' echo 'DEBUG allNewMessages: final result shape = ', ": $ result result;addedRoomIds end. }} NB. updateAllRooms'' NB. allNewMessages'' NB. ] anm =: allNewMessages'' NB. ======================================== NB. IRC SERVER NB. ======================================== NB. NB. CORE DATA STRUCTURES: NB. socket - integer socket descriptor from jsocket NB. nick - character string, client nickname NB. words - boxed list of strings: <'CMD';'arg1';'arg2';... NB. channel - character string starting with '#' NB. rooms - 2D boxed array from Webex: (title;id;lastActivity) per row NB. NB. IRC MESSAGE FORMAT: NB. All messages end with CR,LF (carriage return + line feed) NB. Numeric replies: :servername NNN nickname params NB. Server messages: :servername COMMAND params NB. Client messages: :nick!user@host COMMAND params NB. NB. ======================================== require 'socket' NB. Send IRC message with CRLF NB. Usage: 'message text' ircMsg socket NB. x: character string - raw IRC message (CRLF added automatically) NB. y: integer - socket descriptor NB. Returns: integer - number of bytes sent ircMsg =: 4 : 0 coinsert 'jsocket' if. ioEnable do. echo '--> ', x end. if. 0 < #x do. 'rc sent' =. (x,CR,LF) sdsend y , 0 sent else. 'rc sent' =. (CR,LF) sdsend y , 0 sent end. ) NB. Send numeric reply to client NB. Usage: 'rest of params' ircNumeric code;nick;socket NB. x: character string - parameters/message after the numeric code NB. y: boxed list - (numeric code ; nickname ; socket) NB. numeric code: integer 001-999 (e.g., 001 for RPL_WELCOME) NB. nickname: character string NB. socket: integer socket descriptor NB. Returns: integer - bytes sent NB. Example: ':Welcome' ircNumeric 001;'alice';sock NB. Sends: :webex.local 001 alice :Welcome ircNumeric =: 4 : 0 'num nick sock' =. y numstr =. }. ": 1000 + num NB. Format as 3-digit: 1001 -> '001' (':webex.local ',numstr,' ',nick,' ',x) ircMsg sock ) NB. Send NOTICE message to client NB. Usage: 'message text' ircNotice nick;socket NB. x: character string - the notice message text NB. y: boxed list - (nickname ; socket) NB. nickname: character string - recipient nick NB. socket: integer socket descriptor NB. Returns: integer - bytes sent NB. Example: 'Cannot find message' ircNotice 'alice';sock NB. Sends: :webex.local NOTICE alice :Cannot find message ircNotice =: 4 : 0 'nick sock' =. y (':webex.local NOTICE ',nick,' :',x) ircMsg sock ) NB. Send server message to client NB. Usage: 'COMMAND params' ircServerMsg socket NB. x: character string - IRC command and parameters NB. y: integer - socket descriptor NB. Returns: integer - bytes sent NB. Example: 'PONG :token' ircServerMsg sock NB. Sends: :webex.local PONG :token ircServerMsg =: 4 : 0 (':webex.local ',x) ircMsg y ) NB. Send PRIVMSG to client with optional timestamp (IRCv3 server-time) NB. Usage: (nick;channel;text) ircPrivmsg socket NB. (nick;channel;text;timestamp) ircPrivmsg socket NB. (nick;channel;text;timestamp;messageId) ircPrivmsg socket NB. x: boxed list - (sender_nick ; target ; message_text [; timestamp [; messageId [; parentId]]]) NB. sender_nick: character string - who sent the message NB. target: character string - channel or nick receiving message NB. message_text: character string - the message content NB. timestamp: character string (optional) - ISO 8601 timestamp NB. messageId: character string (optional) - Webex message ID for replies NB. parentId: character string (optional) - Parent message ID for threaded messages NB. y: integer - socket descriptor NB. Returns: integer - bytes sent ircPrivmsg =: 4 : 0 if. 6 = #x do. 'sender target text timestamp messageId parentId' =. x NB. Get last 3 chars of message ID as suffix suffix =. _3 {. messageId NB. Try to get roomId from channel (may fail for DMs) try. roomId =. > chanToRoomId target NB. Store suffix mapping (suffix ; messageId ; roomId) newSuffix =. 1 3 $ suffix ; messageId ; roomId if. 0 < # messageSuffixes do. NB. Remove existing entry for this suffix if present suffixMatch =. > ( line if. 0 < # lineText do. ('@time=',timestamp,' :',sender,'!user@webex PRIVMSG ',target,' :',lineText) ircMsg y end. end. elseif. 5 = #x do. 'sender target text timestamp messageId' =. x NB. Get last 3 chars of message ID as suffix suffix =. _3 {. messageId NB. Try to get roomId from channel (may fail for DMs) try. roomId =. > chanToRoomId target NB. Store suffix mapping (suffix ; messageId ; roomId) newSuffix =. 1 3 $ suffix ; messageId ; roomId if. 0 < # messageSuffixes do. NB. Remove existing entry for this suffix if present suffixMatch =. > ( line if. 0 < # lineText do. ('@time=',timestamp,' :',sender,'!user@webex PRIVMSG ',target,' :',lineText) ircMsg y end. end. elseif. 4 = #x do. 'sender target text timestamp' =. x NB. Split on newlines and send each line separately lines =. LF cut text for_line. lines do. lineText =. > line if. 0 < # lineText do. ('@time=',timestamp,' :',sender,'!user@webex PRIVMSG ',target,' :',lineText) ircMsg y end. end. else. 'sender target text' =. x NB. Split on newlines and send each line separately lines =. LF cut text for_line. lines do. lineText =. > line if. 0 < # lineText do. (':',sender,'!user@webex PRIVMSG ',target,' :',lineText) ircMsg y end. end. end. ) NB. Parse IRC command line into words NB. Usage: ircParse 'NICK alice' NB. y: character string - raw IRC command line NB. Returns: boxed list of strings - <'NICK';'alice' NB. Empty strings are filtered out ircParse =: 3 : '('' '' cut y) -. <''''' NB. Get parameter N from parsed command words NB. Usage: 0 ircParam words (gets first parameter after command) NB. 1 ircParam words (gets second parameter after command) NB. x: integer - parameter index (0-based, AFTER the command word) NB. y: boxed list - parsed words from ircParse NB. Returns: character string - the parameter, or '' if not found NB. Example: words =: <'JOIN';'#channel';'key' NB. 0 ircParam words -> '#channel' NB. 1 ircParam words -> 'key' ircParam =: 4 : 0 idx =. x + 1 if. idx < #y do. > idx { y else. '' end. ) NB. ======================================== NB. IRC PROTOCOL MESSAGES NB. ======================================== NB. Send registration welcome sequence (IRC numeric replies 001-376) NB. Usage: ircWelcome nick;socket NB. y: boxed list - (nickname ; socket) NB. nickname: character string NB. socket: integer socket descriptor NB. Sends: RPL_WELCOME, RPL_YOURHOST, RPL_CREATED, RPL_MYINFO, NB. RPL_ISUPPORT, RPL_LUSERCLIENT, RPL_MOTDSTART, RPL_MOTD, RPL_ENDOFMOTD ircWelcome =: 3 : 0 'nick sock' =. y ':Welcome to the webexjrcd Webex IRC Bridge' ircNumeric 001;nick;sock ':Your host is webex.local' ircNumeric 002;nick;sock ':This server was created today' ircNumeric 003;nick;sock 'webex.local 0.1 io nt' ircNumeric 004;nick;sock 'CHANTYPES=# NETWORK=Webex :are supported' ircNumeric 005;nick;sock ':There are 1 users on 1 servers' ircNumeric 251;nick;sock ':- webex.local Message of the Day -' ircNumeric 375;nick;sock ': ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': ..'';;::::::;,''. .,cldddddddol;'' ' ircNumeric 372;nick;sock ': .'':::;:::::::::::::;. .:ddddddddddxxxxxxxo, ' ircNumeric 372;nick;sock ': .;::;;;;;;;:::::ccccccc:..looooooooodddxxxxkkkkkx; ' ircNumeric 372;nick;sock ': .'',,''::;;;,,,,,,;;;::::ccccccccclllcccclllooddxxxkkkkkkkx''.... ' ircNumeric 372;nick;sock ': ckkkkkkkxo:,'''''''''''',,,;;::::ccccccc:::::::::cclooddxxkkkkkkOOd:ccc;. ' ircNumeric 372;nick;sock ': kkkkxxxxxxxd:''.....'''''',;;:::::cccc:::;;;;;;;;::cloddxxkkkkkOOOd::ccc. ' ircNumeric 372;nick;sock ': ;kkxxxxxdddddo....... .,;:::::::::::;;;;;;;; .loddxxkkkkOOOko;::cc ' ircNumeric 372;nick;sock ': .kxxxxddddddoo,...''. .,;;:::::::::;;;;;;'' loddxkkkkkkkkk:;:c, ' ircNumeric 372;nick;sock ': kxxxdddddddoo;.... .;;;::::::::;;;;;; lodxxkkkkkkkx;;:c. ' ircNumeric 372;nick;sock ': dxxxdddddddooc... ,;;;::::::;;;,;; loddxxxxxxdc;;:c ' ircNumeric 372;nick;sock ': ''xxxdddddddool,. .,;;;:::::;;;,;'' cloooolc,,;::, ' ircNumeric 372;nick;sock ': xxxdddddddool: ,;;;;::::;;;,, '''',,,'''''',,;:: ' ircNumeric 372;nick;sock ': ,xxdddddddooll. ,,;;;::::;;;;, ....'''''''',,;;:; ' ircNumeric 372;nick;sock ': xxxdddddddoolc '';,;;;:::::;;;,'' ....'''''''''',;;:: ' ircNumeric 372;nick;sock ': .xxxdddddddool: ;;;;;;::::::;;;,. ..'''''''''''''',,;::'' ' ircNumeric 372;nick;sock ': :xxxdddddddool; ;;;;;;;:::::::;;;, .'''''''''''''''',,;;:c ' ircNumeric 372;nick;sock ': oxxxxddddddool; ,:::;;;;::::::::;;,'' .'''''''''''''''',,;;:: ' ircNumeric 372;nick;sock ': dxxxxxdddddoolc,.'';:::;;;;;:::::::::;;,,....'''''''''''''''',,;;:: ' ircNumeric 372;nick;sock ': dxxxxxxddddoolcc:::;;;;;;;::::::::::;;,,'''''''''''''''''',,,;;:: ' ircNumeric 372;nick;sock ': cxxxxxxxdddoollcc:::::::::::::::::::;;;,,'''''''''',,,;;;:: ' ircNumeric 372;nick;sock ': .xxxxxxxddddoolllcccccccc:::::::::::;;;;,,,,,,;;;::'' ' ircNumeric 372;nick;sock ': ''xxxxxxddddoooolllllll, .:::::::::;;;;;;;;;;::, ' ircNumeric 372;nick;sock ': lxxxddddddooooooo ,:::::::;;;;;;;:: ' ircNumeric 372;nick;sock ': lddddoo ,:::;;; ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': IRC to Webex Bridge ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': Threaded Replies * Channel Log * Message Delete ' ircNumeric 372;nick;sock ': ;r[eply] abc . ;l[og] [10] . ;d[elete] abc ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': Direct Chat * Message Edits * Action Messages ' ircNumeric 372;nick;sock ': /msg nick . s/old/new . /me action ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': Room Listings * Full help * User Status ' ircNumeric 372;nick;sock ': /list . ;help . /whois ' ircNumeric 372;nick;sock ': ' ircNumeric 372;nick;sock ': 1 { room channel =. > 2 { room NB. Already cleaned channel name origTitle =. > 3 { room NB. Original title NB. Show format: channel 1 :OriginalTitle :: roomId (channel,' 1 :', origTitle, ' :: ', roomId) ircNumeric 322;nick;sock end. ':End of LIST' ircNumeric 323;nick;sock ) NB. Send JOIN confirmation to client NB. Usage: ircJoin nick;socket;channel NB. y: boxed list - (nickname ; socket ; channel) NB. nickname: character string NB. socket: integer socket descriptor NB. channel: character string - channel name (with # prefix) NB. Sends: JOIN message, last 100 messages (with timestamps), RPL_NAMREPLY (353), RPL_ENDOFNAMES (366) ircJoin =: 3 : 0 'nick sock chan' =. y (':',nick,'!user@webex JOIN ',chan) ircMsg sock NB. Send topic (original title :: roomId) topic =. getRoomTopic chan (chan, ' :', topic) ircNumeric 332;nick;sock roomId =. > chanToRoomId chan NB. Replay last 100 messages from channel NB. msgs =. |. 100 messages roomId NB. NB. msgs format: (created ; text ; id ; nick) per row NB. NB. Send each message with timestamp NB. for_msg. msgs do. NB. 'timestamp text id sender' =. msg NB. timestamp =. > timestamp NB. text =. > text NB. sender =. > sender NB. msgId =. > id NB. (sender;chan;text;timestamp;msgId) ircPrivmsg sock NB. end. NB. Send NAMES list (chan;nick;sock) ircSendNames roomId ) NB. Send NAMES list for a room, splitting into multiple 353 replies if needed NB. Usage: ircSendNames channel;nick;socket;roomId NB. y: character string - room ID NB. x: boxed list - (channel ; nickname ; socket) NB. IRC line limit is 512 bytes including CR LF NB. Format: :webex.local 353 = :\r\n ircSendNames =: 4 : 0 'chan nick sock' =. x y debug 'ircSendNames : y' ($ y) debug 'ircSendNames : $y' nicklist =. 0}"_1 membersDetail y NB. Calculate max nicks length: 512 - overhead - 2 (CR LF) NB. Overhead: ':webex.local 353 ' + nick + ' = ' + chan + ' :' overhead =. 21 + (#nick) + 3 + (#chan) + 2 maxlen =. 512 - overhead - 2 NB. Build chunks that fit within maxlen chunks =. '' current =. '' for_n. nicklist do. n =. > n NB. Add space separator if not first nick test =. current , ((*#current) # ' ') , n if. (#test) > maxlen do. NB. Current chunk full, save it and start new one if. 0 < #current do. chunks =. chunks , chunk) ircNumeric 353;nick;sock end. NB. Send end of names (chan,' :End of NAMES list') ircNumeric 366;nick;sock ) NB. Send PONG response to PING NB. Usage: ircPong socket;token NB. y: boxed list - (socket ; token) NB. socket: integer socket descriptor NB. token: character string - ping token to echo back ircPong =: 3 : 0 'sock token' =. y ('PONG webex.local :',token) ircServerMsg sock ) NB. Send WHOIS response (detailed user info) NB. Usage: ircWhois nick;socket;target_nick;user_info NB. y: boxed list - (nickname ; socket ; target_nick ; user_info) NB. nickname: character string - requesting user's nick NB. socket: integer socket descriptor NB. target_nick: character string - nick being queried NB. user_info: boxed list - (username ; hostname ; server ; realname ; channels) NB. username: character string - e.g., 'jdoe' NB. hostname: character string - e.g., 'webex.local' NB. server: character string - e.g., 'webexapis.com' NB. realname: character string - e.g., 'John Doe [active] (Software Engineer)' NB. channels: character string - space-separated, e.g., '#chat #team #general' NB. Example: userinfo =. 'jdoe';'webex.local';'webexapis.com';'John Doe [active] (Engineer)';'#chat #team' NB. ircWhois ('alice';sock;'jdoe'), * : (target,' ',username,' ',hostname,' * :',realname) ircNumeric 311;nick;sock NB. RPL_WHOISSERVER (312): : (target,' ',server,' :Webex IRC Bridge') ircNumeric 312;nick;sock NB. RPL_WHOISCHANNELS (319): : if. 0 < #channels do. (target,' :',channels) ircNumeric 319;nick;sock end. NB. RPL_ENDOFWHOIS (318): :End of WHOIS list (target,' :End of WHOIS list') ircNumeric 318;nick;sock ) webexWho =: 3 : 0 NB. same as webexWho, but with the data already locally NB. used for mass channel who NB. y:data list =. '' for_user. >y do. username =. > 'nickName' select_ejson_ user hostname =. 'webex.local' server =. 'webexapis.com' nick =. trimEmailNick@> 'userName' select_ejson_ user status =. > 'status' select_ejson_ user goneStates =. ('inactive';'OutOfOffce';'pending';'unknown') if. ( 'title' select_ejson_ user NB. realname =. (>'displayName' select_ejson_ user), ' [', status, '] (', title, ')' realname =. (>'displayName' select_ejson_ user) list =. list, nick;username;hostname;server;flag;realname end. (_ 6) $ list : NB. x:nick y:room NB. username, hostname, server, nick, flag, realname NB. ('nickName';'displayName';'status';'title') selector_ejson_ dec_ejson_ apic webexapi 'people/', y detail =. membersDetail > chanToRoomId y mask =. +/ |: > ( 1} {. mask # detail all =. dec_ejson_ apic webexapi 'people/', id username =. > 'nickName' select_ejson_ all hostname =. 'webex.local' server =. 'webexapis.com' nick =. trimEmailNick@> 'userName' select_ejson_ all status =. > 'status' select_ejson_ all goneStates =. ('inactive';'OutOfOffce';'pending';'unknown') if. ( 'title' select_ejson_ all realname =. (>'displayName' select_ejson_ all), ' [', status, '] (', title, ')' nick;username;hostname;server;flag;realname ) ircWho =: 3 : 0 'nick sock target userlist' =. y NB. Send RPL_WHOREPLY (352) for each user for_user. userlist do. 'unick username hostname server flag realname' =. user NB. Validate that we have all required fields with non-empty values if. (-. 0 = #$unick) *. (0 < #unick) *. (0 < #username) *. (0 < #hostname) do. NB. Use defaults for empty optional fields if. 0 = #server do. server =. 'webex.local' end. if. 0 = #flag do. flag =. 'H' end. if. 0 = #realname do. realname =. username end. msg =. target,' ',username,' ',hostname,' ', server ,' ',unick,' ', flag ,' :0 ',realname msg ircNumeric 352;nick;sock end. end. NB. RPL_ENDOFWHO (315): :End of WHO list (target,' :End of WHO list') ircNumeric 315;nick;sock ) NB. ======================================== NB. IRC COMMAND HANDLERS NB. ======================================== NB. NB. HANDLER SIGNATURE: NB. All handlers receive: (nick;socket) ircHandleXXX words NB. x: boxed list - (nickname ; socket) NB. nickname: character string - current client nickname NB. socket: integer socket descriptor NB. y: boxed list - parsed command words from ircParse NB. Example: <'JOIN';'#channel' NB. Returns: character string - new nickname, or special codes: NB. '' - no nickname change NB. 'USER' - signals USER command received NB. 'CAPLS' - signals CAP LS received (entering CAP negotiation) NB. 'CAPEND' - signals CAP END received (exiting CAP negotiation) NB. 'EXIT' - signals server shutdown requested NB. - any other string is treated as new nickname NB. NB. ======================================== NB. Handle NICK command (set/change nickname) NB. Returns: new nickname string, or '' if invalid ircHandleNICK =: 4 : 0 'nick sock words' =. x, 0 ircParam words select. subcmd case. 'LS' do. ':webex.local CAP * LS :server-time' ircMsg sock 'CAPLS' case. 'REQ' do. NB. Always acknowledge server-time capability ':webex.local CAP * ACK :server-time' ircMsg sock '' NB. Stay in CAP negotiation case. 'END' do. 'CAPEND' case. do. '' end. else. '' end. ) NB. Handle PING command (keepalive) NB. Returns: '' (no state change) ircHandlePING =: 4 : 0 'nick sock words' =. x, channel chan debug 'ircHandleJOIN : processing channel' NB. Check if joining by roomId (starts with Y2lzY29zcGFyazovL3VzL1JPT00v) if. 'Y2lzY29zcGFyazovL3VzL1JPT00v' -: 28 {. chan do. 'Joining by room ID' debug 'ircHandleJOIN' NB. Look up channel name by roomId try. chan =. > roomIdToChannel chan chan debug 'ircHandleJOIN : resolved to channel' catch. 'ERROR: Room not found' debug 'ircHandleJOIN' ('Room not found: ', chan) ircNotice nick;sock continue. end. end. 'Joining channel' debug 'ircHandleJOIN' ircJoin nick;sock;chan NB. Track this channel for the client sock clientJoin chan 'Client joined channel' debug 'ircHandleJOIN' end. else. 'WARNING: No channel specified' debug 'ircHandleJOIN' end. '' ) NB. Handle PART command (leave a channel locally without leaving Webex room) NB. Returns: '' (no state change) ircHandlePART =: 4 : 0 'nick sock words' =. x, channel chan debug 'ircHandlePART : leaving channel' NB. Send PART message to client (':',nick,'!user@webex PART ',chan) ircMsg sock NB. Remove this channel from client tracking sock clientPart chan 'Client removed from channel' debug 'ircHandlePART' end. else. NB. No channel specified - return error 'WARNING: No channel specified' debug 'ircHandlePART' 'PART :Not enough parameters' ircNumeric 461;nick;sock end. '' ) NB. Handle TOPIC command (query channel topic) NB. Returns: '' (no state change) ircHandleTOPIC =: 4 : 0 'nick sock words rawline' =. x,y if. 1 < #words do. channel =. 0 ircParam words NB. Get topic using local data (original title :: roomId) topic =. getRoomTopic channel (channel,' :', topic) ircNumeric 332;nick;sock else. NB. No channel specified ':Not enough parameters' ircNumeric 461;nick;sock end. '' ) NB. Handle NAMES command (list users in channel) NB. Returns: '' (no state change) ircHandleNAMES =: 4 : 0 'nick sock words' =. x, chanToRoomId channel (channel;nick;sock) ircSendNames roomId else. NB. NAMES without channel - list all channels NB. Most IRC clients don't use this, but we can implement it for_room. rooms do. channel =. > 2 { room roomId =. > 1 { room (channel;nick;sock) ircSendNames roomId end. end. '' ) NB. Handle MODE command (query/set channel or user modes) NB. Returns: '' (no state change) ircHandleMODE =: 4 : 0 'nick sock words' =. x, ( 1 { suffixData NB. Check for /me command in reply and format as action if. '/me ' -: 4 {. message do. actionText =. 4 }. message NB. Get text after '/me ' formattedMessage =. '*', (> myName), ' ', (dltb actionText), '*' formattedMessage webexReply parentId;roomId;target;nick else. message webexReply parentId;roomId;target;nick end. else. ('Cannot find message with suffix [', suffix, ']') ircNotice nick;sock end. else. ('No messages available to reply to') ircNotice nick;sock end. end. 1 NB. Handled ) NB. Handle ;delete / ;d command NB. Usage: text handleDelete nick;sock handleDelete =: 4 : 0 'nick sock' =. y text =. x NB. Check if this is ;delete or ;d suffix =. '' if. ';delete ' -: 8 {. text do. suffix =. dltb 8 }. text elseif. ';d ' -: 3 {. text do. suffix =. dltb 3 }. text else. 0 return. NB. Not a delete command end. if. 0 < # suffix do. NB. Look up message ID by suffix if. 0 < # messageSuffixes do. suffixes =. 0 {"1 messageSuffixes suffixMatch =. > ( 1 { suffixData NB. Delete the message try. webexDelete msgId ('Deleted message [', suffix, ']') ircNotice nick;sock NB. Remove from suffix tracking messageSuffixes =: ((-.suffixMatch) # messageSuffixes) catch. ('Failed to delete message [', suffix, ']') ircNotice nick;sock end. else. ('Cannot find message with suffix [', suffix, ']') ircNotice nick;sock end. else. ('No messages available to delete') ircNotice nick;sock end. else. ('Usage: ;delete or ;d ') ircNotice nick;sock end. 1 NB. Handled ) NB. Handle s/old/new command NB. Usage: text handleEdit nick;sock;target;roomId handleEdit =: 4 : 0 'nick sock target roomId' =. y text =. x NB. Check if this is s/old/new format if. -. (('s/' -: 2 {. text) *. (1 < +/ text = '/')) do. 0 return. NB. Not an edit command end. NB. Parse s/old/new parts =. <;._2 text, '/' if. 3 <: # parts do. old =. > 1 { parts new =. > 2 { parts NB. Find last message for this nick in this channel if. 0 < # lastMessages do. found =. 0 foundIdx =. 0 NB. Iterate through boxes to find matching channel/nick for_i. i. # lastMessages do. items =. > i { lastMessages msgChannel =. > 0 { items msgNick =. > 1 { items if. (target -: msgChannel) *. (nick -: msgNick) do. found =. 1 foundIdx =. i end. end. if. found do. items =. > foundIdx { lastMessages msgId =. > 2 { items oldText =. > 3 { items NB. Perform replacement newText =. oldText rplc old;new newText webexUpdate msgId;roomId NB. Update stored text updatedMsg =. < target ; nick ; msgId ; newText lastMessages =: updatedMsg foundIdx } lastMessages else. ('No previous message to edit in ', target) ircMsg sock end. else. ('No previous message to edit in ', target) ircMsg sock end. end. 1 NB. Handled ) NB. Handle ;log command NB. Usage: text handleLog nick;sock;target;roomId handleLog =: 4 : 0 'nick sock target roomId' =. y text =. x NB. Check if this is ;log or ;l if. ';log ' -: 5 {. text do. logOffset =. 5 elseif. ';l ' -: 3 {. text do. logOffset =. 3 elseif. (';log' -: 4 {. text) +. (';l' -: 2 {. text) do. logOffset =. # text else. 0 return. NB. Not a log command end. NB. Parse optional count parameter, default to 100 count =. 100 if. logOffset < # text do. try. param =. dltb logOffset }. text count =. ". param if. (0 = count) +. (0 > count) do. count =. 100 end. catch. count =. 100 end. end. NB. Fetch and display last messages try. msgs =. |. count messages roomId NB. msgs format: (timestamp ; text ; id ; nick ; parentId) NB. Send each message to client if. 0 < # msgs do. for_i. i. # msgs do. row =. i { msgs timestamp =. > 0 { row msgText =. > 1 { row msgId =. > 2 { row msgSender =. > 3 { row parentId =. > 4 { row NB. Send to client: (sender ; channel ; text ; timestamp ; messageId ; parentId) msgArgs =. msgSender ; target ; msgText ; timestamp ; msgId ; parentId msgArgs ircPrivmsg sock end. else. ('No messages in channel history') ircNotice nick;sock end. catch. ('Failed to fetch message history') ircNotice nick;sock end. 1 NB. Handled ) NB. Handle ;help command NB. Usage: text handleHelp nick;sock handleHelp =: 4 : 0 'nick sock' =. y text =. x if. -. (';help' -: text) do. 0 return. NB. Not a help command end. NB. Display help for custom commands as NOTICE messages '=== Webex Bridge Commands ===' ircNotice nick;sock ' ;help - Show this help message' ircNotice nick;sock ' ;log [count] - Fetch last messages (default: 100)' ircNotice nick;sock ' ;l [count] - Alias for ;log' ircNotice nick;sock ' ;reply - Reply to a message by its [abc] suffix' ircNotice nick;sock ' ;r - Alias for ;reply' ircNotice nick;sock ' ;delete - Delete a message by its [abc] suffix' ircNotice nick;sock ' ;d - Alias for ;delete' ircNotice nick;sock ' s/old/new - Edit your last message (sed syntax)' ircNotice nick;sock ' /me - Send an action message (*name action*) works after ;r abc too!' ircNotice nick;sock '' ircNotice nick;sock '=== Authentication (PASS command) ===' ircNotice nick;sock ' Format 1: PASS ' ircNotice nick;sock ' Personal access token (expires in ~12 hours)' ircNotice nick;sock ' Format 2: PASS :' ircNotice nick;sock ' Starts OAuth flow, returns refresh token' ircNotice nick;sock ' Format 3: PASS ::' ircNotice nick;sock ' Uses saved refresh token (lasts ~90 days)' ircNotice nick;sock '' ircNotice nick;sock '=== Standard IRC Commands ===' ircNotice nick;sock ' /list - List all Webex rooms with roomIds' ircNotice nick;sock ' /join - Join room by name or roomId from /list' ircNotice nick;sock ' /part - Close channel buffer (rejoin to reopen)' ircNotice nick;sock ' /topic [channel] - View Webex room title' ircNotice nick;sock ' /msg - Send direct message to Webex user' ircNotice nick;sock ' /names [channel] - List members in Webex room' ircNotice nick;sock ' /whois - Show Webex user info (status, title)' ircNotice nick;sock ' /who - List members with status in room' ircNotice nick;sock ' /mode - Query channel/user modes' ircNotice nick;sock ' /userhost - Get user@host info' ircNotice nick;sock ' /ison [nick...] - Check if users are online' ircNotice nick;sock ' /time - Show server time' ircNotice nick;sock ' /version - Show server version' ircNotice nick;sock ' /lusers - Show user/channel statistics' ircNotice nick;sock ' /stats - Show server statistics' ircNotice nick;sock ' /quit [message] - Disconnect from bridge' ircNotice nick;sock '' ircNotice nick;sock '=== Message Format ===' ircNotice nick;sock 'Each message shows a [abc] suffix for use with ;reply and ;delete' ircNotice nick;sock 'Thread replies show as: roomIdResult roomId debug 'ircHandlePRIVMSG : roomId after unboxing' ($ roomId) debug 'ircHandlePRIVMSG : shape of roomId' NB. Try special command handlers in order if. text handleReply nick;sock;target;roomId do. 'Reply handler triggered' debug 'ircHandlePRIVMSG' elseif. text handleDelete nick;sock do. 'Delete handler triggered' debug 'ircHandlePRIVMSG' elseif. text handleEdit nick;sock;target;roomId do. 'Edit handler triggered' debug 'ircHandlePRIVMSG' elseif. text handleLog nick;sock;target;roomId do. 'Log handler triggered' debug 'ircHandlePRIVMSG' elseif. text handleHelp nick;sock do. 'Help handler triggered' debug 'ircHandlePRIVMSG' else. 'No special handler - proceeding to send' debug 'ircHandlePRIVMSG' NB. Check for IRC ACTION (CTCP ACTION or plain ACTION) isAction =. 0 actionText =. '' NB. Check for CTCP ACTION format: \x01ACTION text\x01 if. (1{a.) = {. text do. NB. CTCP format with \x01 delimiters strippedText =. }. text NB. Remove the \x01 if. 'ACTION ' -: 7 {. strippedText do. NB. Extract action text after 'ACTION ' actionText =. 7 }. strippedText NB. Skip 'ACTION ' NB. Remove trailing \x01 if present if. (0 < # actionText) *. ((1{a.) = {: actionText) do. actionText =. }: actionText end. isAction =. 1 end. NB. Check for plain ACTION format (some clients/bridges) elseif. 'ACTION ' -: 7 {. text do. actionText =. 7 }. text isAction =. 1 NB. Check for manual /me command elseif. '/me ' -: 4 {. text do. actionText =. 4 }. text isAction =. 1 end. if. isAction do. NB. Unbox myName if it's boxed unboxedName =. > myName formattedText =. '*', unboxedName, ' ', (dltb actionText), '*' formattedText webexSend roomId;target;nick else. NB. Regular message send 'About to call webexSend' debug 'ircHandlePRIVMSG' text debug 'ircHandlePRIVMSG : text to send' roomId debug 'ircHandlePRIVMSG : roomId for webexSend' text webexSend roomId;target;nick 'webexSend called' debug 'ircHandlePRIVMSG' end. end. else. NB. Target is a nick - send direct message try. personId =. nickToId target if. 0 < # personId do. NB. Check for IRC ACTION in direct message isAction =. 0 actionText =. '' NB. Check for CTCP ACTION format: \x01ACTION text\x01 if. (1{a.) = {. text do. if. 'ACTION ' -: 7 {. }. text do. actionText =. 7 }. }. text if. (1{a.) = {: actionText do. actionText =. }: actionText end. isAction =. 1 end. elseif. 'ACTION ' -: 7 {. text do. actionText =. 7 }. text isAction =. 1 elseif. '/me ' -: 4 {. text do. actionText =. 4 }. text isAction =. 1 end. if. isAction do. formattedText =. '*', (> myName), ' ', (dltb actionText), '*' result =. formattedText webexSendDirect personId;target;nick else. result =. text webexSendDirect personId;target;nick end. messageId =. > 0 { result timestamp =. > 1 { result roomId =. > 2 { result NB. Add the DM room to rooms list if not already present NB. Use the target nick as the channel name channelName =. target if. 0 < # rooms do. NB. Check if this room already exists roomIds =. 1 {"1 rooms roomMatch =. > ( chanToRoomId target do. NB. memberNick =. > i NB. whoData =. memberNick webexWho target NB. row =. ( chanToRoomId target ircWho (nick;sock;target), : ('webexjrcd-0.1 webex.local :Webex Teams IRC Bridge') ircNumeric 351;nick;sock '' ) NB. Handle USERHOST command (query user@host for nicks) NB. Returns: '' (no state change) ircHandleUSERHOST =: 4 : 0 'nick sock words' =. x, 0 { client clientNick =. > 1 { client clientChans =. > 2 { client NB. Count joined channels if. 32 = 3!:0 clientChans do. chanCount =. # clientChans else. chanCount =. 0 end. (' Client: ', clientNick, ' (socket ', (": clientSock), ', ', (": chanCount), ' channels joined)') ircNumeric 249;nick;sock end. end. NB. Message tracking stats ('Tracked messages for editing: ', (": # lastMessages)) ircNumeric 249;nick;sock ('Message suffixes for replies: ', (": # messageSuffixes)) ircNumeric 249;nick;sock NB. My nick ('Bridge user nick: ', myNick) ircNumeric 249;nick;sock NB. Token status if. 'Bearer ' -: 7 {. token_apic_ do. ('Auth token: set') ircNumeric 249;nick;sock else. ('Auth token: not set') ircNumeric 249;nick;sock end. NB. RPL_ENDOFSTATS (219) ('* :End of STATS report') ircNumeric 219;nick;sock '' ) NB. Handle QUIT command (client disconnect) NB. Returns: '' (no state change; caller handles disconnect via return value from ircRoute) ircHandleQUIT =: 4 : 0 'nick sock words' =. x, {. words cmd debug 'ircRoute : routing command' nick debug 'ircRoute : from nick' select. cmd case. 'NICK' do. 1;(nick;sock) ircHandleNICK words case. 'USER' do. 1; ircHandleUSER words case. 'PASS' do. 1;(nick;sock) ircHandlePASS words case. 'CAP' do. 1;(nick;sock) ircHandleCAP words case. 'PING' do. 1;((nick;sock) ircHandlePING words) case. 'LIST' do. 1;((nick;sock) ircHandleLIST words) case. 'JOIN' do. 1;((nick;sock) ircHandleJOIN words) case. 'PART' do. 1;((nick;sock) ircHandlePART words) case. 'TOPIC' do. 1;((nick;sock) ircHandleTOPIC words;y) case. 'NAMES' do. 1;((nick;sock) ircHandleNAMES words) case. 'MODE' do. 1;((nick;sock) ircHandleMODE words) case. 'PRIVMSG' do. 1;((nick;sock) ircHandlePRIVMSG words;y) case. 'WHOIS' do. 1;((nick;sock) ircHandleWHOIS words) case. 'WHO' do. 1;((nick;sock) ircHandleWHO words) case. 'TIME' do. 1;((nick;sock) ircHandleTIME words) case. 'VERSION' do. 1;((nick;sock) ircHandleVERSION words) case. 'USERHOST' do. 1;((nick;sock) ircHandleUSERHOST words) case. 'ISON' do. 1;((nick;sock) ircHandleISON words) case. 'LUSERS' do. 1;((nick;sock) ircHandleLUSERS words) case. 'STATS' do. 1;((nick;sock) ircHandleSTATS words) case. 'QUIT' do. 0;((nick;sock) ircHandleQUIT words) case. 'EXIT' do. 0;((nick;sock) ircHandleEXIT words) case. 'RESTART' do. 0;((nick;sock) ircHandleRESTART words) case. do. ('WARNING: Unknown command: ', cmd) debug 'ircRoute' 1;'' end. ) NB. ======================================== NB. IRC SERVER MAIN LOOP NB. ======================================== NB. Start IRC server NB. Usage: ircserver '' NB. y: ignored (use empty string '') NB. NB. SERVER STATE: NB. serverRunning - integer boolean, controls main accept loop NB. lsock - integer, listening socket descriptor NB. NB. CLIENT STATE (per connection): NB. nick - character string, client nickname (starts as '*') NB. hasNick - integer boolean, 1 if NICK received NB. hasUser - integer boolean, 1 if USER received NB. inCap - integer boolean, 1 if in CAP negotiation NB. registered - integer boolean, 1 if welcome sent NB. csock - integer, client socket descriptor NB. connected - integer boolean, controls client loop NB. NB. REGISTRATION SEQUENCE: NB. 1. Client sends: CAP LS (optional) NB. 2. Client sends: NICK NB. 3. Client sends: USER ... NB. 4. Client sends: CAP END (if CAP negotiation started) NB. 5. Server sends: ircWelcome (001-376) NB. NB. GLOBAL DEPENDENCIES: NB. rooms - 2D boxed array from Webex (used by LIST command) NB. ircserver =: 3 : 0 coinsert 'jsocket' 'Starting IRC server' debug 'ircserver' NB. Create and configure socket 'rc lsock' =. sdsocket 2 1 0 if. rc ~: 0 do. 'ERROR: Socket creation failed' debug 'ircserver' echo 'Socket error' return. end. 'Socket created successfully' debug 'ircserver' sdsetsockopt lsock ; SOL_SOCKET ; SO_REUSEADDR ; 1 rc =. sdbind lsock ; 2 ; '0.0.0.0' ; ircPort if. rc ~: 0 do. ('ERROR: Bind failed on port ', ": ircPort) debug 'ircserver' echo 'Bind failed' return. end. ('Successfully bound to port ', ": ircPort) debug 'ircserver' sdlisten lsock , 5 echo 'Listening on port ', ":ircPort ('Server listening on port ', ": ircPort) debug 'ircserver' serverRunning =. 1 while. serverRunning do. NB. Accept connection 'rc csock' =. sdaccept lsock if. rc ~: 0 do. continue. end. NB. Note: Socket timeout not used - we use sdselect instead for non-blocking I/O NB. Initialize client state nick =. '*' hasNick =. 0 hasUser =. 0 hasPass =. 0 passToken =. '' authenticated =. 0 inCap =. 0 registered =. 0 lastPollTime =. 0 NB. Track last message poll time NB. Client loop connected =. 1 while. connected do. NB. Use sdselect to check if data is available (2 second timeout) NB. Format: sdselect read_socks ; write_socks ; error_socks ; timeout_seconds NB. Returns: rc ; ready_reads ; ready_writes ; ready_errors try. NB. Check for data with very short timeout (100ms) selectResult =. sdselect csock ; '' ; '' ; 0.1 selectRc =. > 0 { selectResult readyReads =. > 1 { selectResult if. selectRc = 0 do. if. ('' -: readyReads) +. (0 = readyReads) do. msg =. '' NB. No data available NB. Sleep for 1 second to avoid spinning 6!:3 ] 1 else. 'rc msg' =. sdrecv csock , 4096 , 0 if. (rc ~: 0) +. (0 = #msg) do. connected =. 0 continue. end. end. else. connected =. 0 NB. sdselect error continue. end. catch. NB. Fall back to blocking recv if sdselect fails 'rc msg' =. sdrecv csock , 4096 , 0 if. (rc ~: 0) +. (0 = #msg) do. connected =. 0 continue. end. end. if. 0 < #msg do. if. ioEnable do. echo '<-- ', msg end. end. NB. Process lines (only if we have data) if. 0 < #msg do. if. LF ~: {: msg do. msg =. msg,LF end. lines =. <;._2 msg rplc CR;'' for_line. lines do. NB. Check if command requires authentication lineParsed =. > line lineWords =. ircParse lineParsed if. 0 < #lineWords do. lineCmd =. > {. lineWords NB. Allow only registration commands before authentication allowedBeforeAuth =. 'PASS';'NICK';'USER';'CAP';'PING';'QUIT' if. (-.authenticated) *. (-.( result firstElem =. dltb > 0 { unboxed NB. Trim trailing spaces if. (2 = #unboxed) *. ('PASS' -: firstElem) do. passToken =. dltb > 1 { unboxed NB. Also trim the token hasPass =. 1 end. elseif. 0 < #result do. NB. Regular string result - treat as nick nick =. result hasNick =. 1 end. end. end. NB. Check if trying to register without PASS (after processing all commands) if. ((hasNick *. hasUser *. -.inCap) *. -.registered) *. -.hasPass do. NB. Reject connection - no password provided echo 'Authentication failed: no password provided for ', nick ':Password required' ircNumeric 464;nick;csock 'ERROR :Password required' ircServerMsg csock connected =. 0 end. NB. Authenticate and send welcome when ready (after processing all commands) if. ((hasNick *. hasUser *. hasPass *. -.inCap) *. -.registered) *. -.authenticated do. NB. Remove leading colon if present (from IRC protocol) cleanToken =. passToken if. (':' = {. cleanToken) do. cleanToken =. }. cleanToken end. NB. Parse authentication format colonCount =. +/ cleanToken = ':' colonCount debug 'colonCount' if. 0 = colonCount do. NB. Format 1: Bearer token only useBearer cleanToken actualNick =. getMyNick '' NB. Verify nick matches if. nick -: actualNick do. NB. Authentication successful authenticated =. 1 NB. Load all rooms once after authentication 'Fetching all rooms...' ircNotice nick;csock updateAllRooms '' ('Found ', (": # rooms), ' rooms') ircNotice nick;csock NB. Send welcome ircWelcome nick;csock registered =. 1 NB. Add client to global tracking csock clientAdd nick elseif. 0 = # actualNick do. NB. Authentication failed - empty nick means token is expired or invalid 'Authentication failed. Your Bearer token is expired or invalid.' ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 else. NB. Authentication failed - nick mismatch ('Authentication failed. Token belongs to ''', actualNick, ''', not ''', nick, '''') ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 end. elseif. 1 = colonCount do. NB. Format 2: clientId:clientSecret - start OAuth flow parts =. <;._1 ':', ':', cleanToken clientId =. > 1 { parts clientSecret =. > 2 { parts NB. Start OAuth flow 'authUrl state port clientId clientSecret nick2' =. startOAuth clientId;clientSecret;csock;nick (authUrl;state;port;clientId;clientSecret;nick2) debug 'startOAuth : returns' NB. Send OAuth instructions to user '=== OAuth Authentication Required ===' ircNotice nick;csock 'Please open this URL in your browser:' ircNotice nick;csock authUrl ircNotice nick;csock ('Waiting for authorization on port ', (": port), '...') ircNotice nick;csock 'You have 3 minutes to complete the authorization' ircNotice nick;csock NB. Start OAuth callback server (blocking) refreshToken =. port startOAuthServer state;clientId;clientSecret;csock;nick if. 0 < # refreshToken do. NB. OAuth successful - send refresh token to user '=== OAuth Authentication Successful ===' ircNotice nick;csock (nick, ': Save this for future connections:') ircNotice nick;csock ('PASS ', clientId, ':', clientSecret, ':', refreshToken) ircNotice nick;csock 'This refresh token expires in ~90 days' ircNotice nick;csock NB. Now authenticate with the new token actualNick =. getMyNick '' if. nick -: actualNick do. authenticated =. 1 'Fetching all rooms...' ircNotice nick;csock updateAllRooms '' ('Found ', (": # rooms), ' rooms') ircNotice nick;csock ircWelcome nick;csock registered =. 1 csock clientAdd nick else. 'OAuth succeeded but nick mismatch' ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 end. else. NB. OAuth failed 'OAuth authentication timed out after 3 minutes' ircNotice nick;csock 'Please try again and complete authorization within the time limit' ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 end. elseif. 2 = colonCount do. NB. Format 3: clientId:clientSecret:refreshToken parts =. <;._1 ':', ':', cleanToken clientId =. > 1 { parts clientSecret =. > 2 { parts refreshToken =. > 3 { parts NB. Try to refresh the access token result =. refreshAccessToken clientId;clientSecret;refreshToken if. '0' -: result do. NB. Refresh failed - token might be expired 'Refresh token expired or invalid.' ircNotice nick;csock 'Please reconnect with just clientId:clientSecret to re-authorize' ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 else. NB. Check if we got a new refresh token if. ('1' -: result) +. (0 = # result) do. NB. Success with same refresh token - continue silently else. NB. Got new refresh token - notify user with nick mention '=== New Refresh Token ===' ircNotice nick;csock (nick, ': Your refresh token was updated. Save this:') ircNotice nick;csock ('PASS ', clientId, ':', clientSecret, ':', result) ircNotice nick;csock end. NB. Verify authentication nick debug '3 part pass : nick' actualNick =. getMyNick '' actualNick debug '3 part pass : actualNick' if. nick -: actualNick do. authenticated =. 1 'Fetching all rooms...' ircNotice nick;csock updateAllRooms '' ('Found ', (": # rooms), ' rooms') ircNotice nick;csock ircWelcome nick;csock registered =. 1 csock clientAdd nick else. 'Authentication failed - nick mismatch' ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 end. end. else. NB. Invalid format 'Invalid PASS format. Use one of:' ircNotice nick;csock ' PASS ' ircNotice nick;csock ' PASS :' ircNotice nick;csock ' PASS ::' ircNotice nick;csock ':Incorrect password' ircNumeric 464;nick;csock connected =. 0 end. end. end. NB. Poll for new Webex messages periodically (only if authenticated) NB. Only poll if at least 5 seconds have passed since last poll if. registered *. authenticated do. currentTime =. 6!:1 '' if. (currentTime - lastPollTime) > 5 do. lastPollTime =. currentTime pollResult =. allNewMessages'' newMsgs =. > 0 { pollResult addedRoomIds =. 1 { pollResult NB. Keep boxed, don't unbox echo 'DEBUG poll: got ', (": # newMsgs), ' new messages, shape = ', ": $ newMsgs else. newMsgs =. 0 6 $ a: addedRoomIds =. 0$a: end. else. newMsgs =. 0 6 $ a: addedRoomIds =. 0$a: end. if. 0 < #newMsgs do. NB. Check if this client has joined any channels mask =. +/ |: > ( chans NB. Process each message for_msg. newMsgs do. 'timestamp text id sender parentId channel' =. msg NB. channel is already unboxed from the assignment above NB. Check if client has joined this channel if. 32 = 3!:0 chanList do. chanMatch =. ( chanList) end. if. chanMatch do. msgArgs =. (>sender) ; channel ; (>text) ; (>timestamp) ; (>id) ; (>parentId) msgArgs ircPrivmsg csock else. end. end. end. end. NB. Notify client of newly added rooms if. 0 < # addedRoomIds do. NB. For each new room ID, look it up in rooms and get its cleaned name for_i. i. # addedRoomIds do. roomId =. > i { addedRoomIds NB. Get the room ID NB. Find this room in the rooms array existingIds =. 1 {"1 rooms roomMatch =. > ( 2 { roomRow NB. Get cleaned name from column 2 ('You have been added to ', channelName) ircNotice nick;csock end. end. end. end. NB. Remove client from tracking when they disconnect clientRemove csock sdclose csock end. sdclose lsock echo 'Server stopped' NB. Return 'RESTART' if we need to restart, otherwise '' if. serverRunning = _1 do. 'RESTART' else. '' end. ) NB. Main server runner with restart support runServer =: 3 : 0 restart =. 1 while. restart do. result =. ircserver '' if. 'RESTART' -: result do. echo 'Reloading code and restarting server...' load SCRIPT_z_ restart =. 0 NB. Stop loop - the load will restart everything else. restart =. 0 end. end. ) NB. To run: NB. load 'webexjrcd.ijs' NB. ircserver '' runServer'' NB. runServer'' NB. Bind failed NB. 0 exit''