^ ^ ^ ^ =(o.o)= ~byakuren =(o.o)= m m m m
An IRC-to-Webex Teams bridge written in J
webexjrcd is an IRC server that bridges your favorite IRC client to Cisco Webex Teams. Connect using any standard IRC client and interact with your Webex rooms, direct messages, and team conversations through the familiar IRC interface.
Message ID Suffixes: Every message displays with a 3-character suffix derived from its Webex message ID:
alice [x2F] Good morning everyone!
bob [g7H] Has anyone reviewed the PR?Threaded Replies: Reply to any message to create or continue a thread:
;reply x2F I'll take a look!
;r x2F shorthand works too
When you reply to a message, it becomes the parent of a Webex thread.
Message Editing: Edit your last message using sed-style syntax:
s/typo/fixed/Message Deletion: Delete messages by their suffix:
;delete a2c
;d a2cAction Messages: Send /me actions
that render as italicized text in Webex:
/me waves hello -> *YourName waves hello*
;r abc /me agrees -> *YourName agrees* (in thread)Message History: Fetch channel history on demand:
;log (default: 100 messages)
;log 50 (fetch last 50 messages)
;l 25 (shorthand)Multiline Support: Webex multiline messages are automatically split into multiple IRC lines.
/list shows all rooms
with names, titles, and room IDs/join #room1,#room2,#room3/msg nickname messageOption A: Personal Access Token (quick testing, expires in ~12 hours) - Visit developer.webex.com - Copy your personal access token
Option B: OAuth Integration (recommended, lasts ~90
days) - Create an integration at developer.webex.com - Set
redirect URI to http://localhost:8667/callback - Request
spark:all scope - Note your Client ID and Client Secret
jconsole webexjrcd.ijsServer: localhost
Port: 6661
Nickname: your_webex_username (part before @ in your email)
Password: <see Authentication section>
webexjrcd supports three authentication formats via the IRC
PASS command:
PASS <bearerToken>
Personal access tokens from the Webex Developer Portal. Expires in ~12 hours.
PASS <clientId>:<clientSecret>
Triggers browser-based OAuth:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ IRC Client │ │ webexjrcd │ │ Webex OAuth │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ PASS clientId:secret │ │
│───────────────────────>│ │
│ │ │
│ NOTICE: Open this URL │ │
│<───────────────────────│ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ │ User opens URL in browser │ │
│ │ and authorizes the app │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ │ Authorization code │
│ │<───────────────────────│
│ │ │
│ │ Exchange for tokens │
│ │───────────────────────>│
│ │ │
│ │ Access + Refresh token │
│ │<───────────────────────│
│ │ │
│ NOTICE: Save this PASS │ │
│<───────────────────────│ │
│ │ │
│ Welcome messages │ │
│<───────────────────────│ │
You have 3 minutes to complete browser authorization.
PASS <clientId>:<clientSecret>:<refreshToken>
Use a saved refresh token from a previous OAuth flow. Lasts ~90 days. When it’s refreshed, you’ll receive the new token to save.
| Command | Alias | Description |
|---|---|---|
;help |
Show help message | |
;log [N] |
;l [N] |
Fetch last N messages (default: 100) |
;reply <suffix> <msg> |
;r <suffix> <msg> |
Reply to message, creating/continuing thread |
;delete <suffix> |
;d <suffix> |
Delete a message |
s/old/new/ |
Edit your last message | |
/me <action> |
Send action message |
| Command | Description |
|---|---|
/list |
List all Webex rooms |
/join <channel\|roomId> |
Join a room |
/part <channel> |
Leave channel locally |
/topic [channel] |
View room title and ID |
/msg <nick> <message> |
Send direct message |
/names [channel] |
List channel members |
/whois <nick> |
User info with status/title |
/who <channel> |
List members with H/G flags |
/mode <target> |
Query modes |
/userhost <nick> |
Get user@host |
/ison <nick>... |
Check if users online |
/time |
Server time |
/version |
Server version |
/lusers |
User/channel stats |
/stats |
Detailed server stats |
/quit |
Disconnect |
| Command | Description |
|---|---|
EXIT |
Shutdown server |
RESTART |
Reload and restart |
sender [abc] message content
The [abc] suffix is the last 3 characters of the Webex
message ID. This suffix is used to reference messages for replies,
edits, and deletions.
When a message is part of a thread (i.e., it has a
parentId in Webex), it displays with the parent’s
suffix:
alice [x2F] Let's discuss the new feature
bob <x2F| [g7H] I have some ideas
carol <x2F| [k9J] Me too!
The <x2F| prefix indicates this message is a reply in
the thread started by message x2F.
alice [x2F] Let's discuss the new feature ← Thread parent (no prefix)
│
├── bob <x2F| [g7H] I have some ideas ← Reply to x2F
│
└── carol <x2F| [k9J] Me too! ← Reply to x2F
When you use ;reply or ;r, the message you
reference becomes the thread parent:
Scenario 1: Starting a new thread
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
alice [x2F] Anyone seen the new design? ← Standalone message
You type: ;r x2F Looks great!
Result in Webex:
┌─ Thread ─────────────────────────────┐
│ alice: Anyone seen the new design? │ ← parentId = null (thread root)
│ └─ you: Looks great! │ ← parentId = x2F's full ID
└──────────────────────────────────────┘
Displayed in IRC:
alice [x2F] Anyone seen the new design?
you <x2F| [m3P] Looks great!
Scenario 2: Continuing an existing thread
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
alice [x2F] Anyone seen the new design?
bob <x2F| [g7H] I have some ideas
You type: ;r x2F Me too!
Result: Your message joins the same thread as bob's
you <x2F| [k9J] Me too!
Scenario 3: Replying to any message in a thread
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
You can reply to ANY message suffix, and your reply
uses that message's ID as the parentId:
alice [x2F] Anyone seen the new design?
bob <x2F| [g7H] I have some ideas
You type: ;r g7H @bob tell me more
Result in Webex: Creates reply with parentId = g7H's full ID
(This effectively replies to bob's message)
webexjrcd maintains a mapping of suffixes to full message IDs:
┌─────────────────────────────────────────────────────────────┐
│ messageSuffixes table │
├─────────┬──────────────────────────────────────┬────────────┤
│ suffix │ messageId │ roomId │
├─────────┼──────────────────────────────────────┼────────────┤
│ x2F │ Y2lzY29zcGFyazovL3VzL01FU1NBR0Uv... │ Y2lzY29...│
│ g7H │ Y2lzY29zcGFyazovL3VzL01FU1NBR0Uv... │ Y2lzY29...│
│ k9J │ Y2lzY29zcGFyazovL3VzL01FU1NBR0Uv... │ Y2lzY29...│
└─────────┴──────────────────────────────────────┴────────────┘
When you type: ;r x2F my message
1. Look up 'x2F' in messageSuffixes → get full messageId
2. POST to Webex API:
{
"roomId": "Y2lzY29...",
"parentId": "Y2lzY29zcGFyazovL3VzL01FU1NBR0Uv...",
"markdown": "my message"
}
3. Webex creates the message in the thread
┌─────────────────────────────────────────────────────────────────────┐
│ webexjrcd │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ IRC Server │ │ Protocol │ │ Webex API │ │
│ │ │◄──►│ Bridge │◄──►│ Client │ │
│ │ Port 6661 │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Socket │ │ State │ │ REST Calls via │ │
│ │ Handling │ │ Manager │ │ curl/gethttp │ │
│ │ (jsocket) │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ IRC Clients │ │ Webex Cloud │
│ (any) │ │ webexapis.com │
└─────────────┘ └─────────────────┘
OUTBOUND (IRC → Webex)
┌──────────┐ ┌──────────┐
│ User │ PRIVMSG #channel :hello │ Webex │
│ types │ ─────────────────────────────────────────►│ Room │
│ message │ │ │
└──────────┘ └──────────┘
│
▼
┌───────────┐
│ ircRoute │
│ dispatches│
│ to handler│
└─────┬─────┘
│
▼
┌───────────────┐
│ircHandlePRIVMSG│
└───────┬───────┘
│
┌─────────┼─────────┐
▼ ▼ ▼
┌─────────┐ ┌─────┐ ┌─────────┐
│;reply? │ │s/? │ │ regular │
│;delete? │ │edit │ │ message │
│;log? │ │ │ │ │
└────┬────┘ └──┬──┘ └────┬────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│webex │ │webex │ │webexSend │
│Reply │ │Update │ │ │
└─────────┘ └──────────┘ └──────────┘
│ │ │
└─────────┼──────────────┘
▼
POST /v1/messages
{roomId, markdown, [parentId]}
INBOUND (Webex → IRC)
┌──────────┐ every 5 seconds ┌──────────┐
│ Webex │◄──────────────────────────────────────────│webexjrcd │
│ Rooms │ GET /rooms?max=10&sortBy=lastactivity │ │
└──────────┘ └──────────┘
│
▼
┌───────────┐
│updateRooms│
│ (delta) │
└─────┬─────┘
│ rooms with new activity
▼
┌───────────────┐
│ For each room:│
│ GET /messages │
│ ?roomId=X │
└───────┬───────┘
│
▼
┌───────────────┐
│ Filter by │
│ timestamp > │
│ touchTime │
└───────┬───────┘
│ new messages only
▼
┌───────────────┐
│ ircPrivmsg │
│ with [suffix] │
│ and <parent| │
└───────────────┘
│
▼
@time=2024-01-15T10:30:00Z
:alice!user@webex PRIVMSG #channel :[x2F] hello
NB. Room cache: (lastActivity ; roomId ; cleanedName ; originalTitle)
rooms =: 0 4 $ a:
NB. Example:
┌────────────────────┬──────────────────────┬─────────────────┬──────────────────┐
│2024-01-15T10:30:00Z│Y2lzY29zcGFyazovL3Vz..│#Engineering_Team│Engineering Team │
├────────────────────┼──────────────────────┼─────────────────┼──────────────────┤
│2024-01-15T09:15:00Z│Y2lzY29zcGFyazovL3Vz..│#General_Chat │General Chat │
└────────────────────┴──────────────────────┴─────────────────┴──────────────────┘
NB. Connected clients: (socket ; nick ; channels)
clients =: 0 3 $ a:
NB. Message suffix mapping for replies/deletes: (suffix ; messageId ; roomId)
messageSuffixes =: 0 3 $ a:
┌──────┬──────────────────────────────────────┬────────────────────────┐
│ x2F │ Y2lzY29zcGFyazovL3VzL01FU1NBR0Uv... │ Y2lzY29zcGFyazovL3Vz.. │
├──────┼──────────────────────────────────────┼────────────────────────┤
│ g7H │ Y2lzY29zcGFyazovL3VzL01FU1NBR0Uv... │ Y2lzY29zcGFyazovL3Vz.. │
└──────┴──────────────────────────────────────┴────────────────────────┘
NB. Last message per user per channel for editing: list of boxes
NB. Each box contains: (channel ; nick ; messageId ; text)
lastMessages =: 0$a:The bridge uses an efficient delta-polling strategy to minimize API calls:
┌─────────────────────────────────────────────────────────────────┐
│ INITIAL CONNECTION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GET /rooms?max=1000&sortBy=lastactivity │
│ │
│ • Fetch metadata for ALL rooms once │
│ • Store: lastActivity, roomId, title │
│ • Set touchTime = most recent lastActivity │
│ │
│ Result: Full room list cached locally │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ EVERY 5 SECONDS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. GET /rooms?max=10&sortBy=lastactivity │
│ └─► Only fetch 10 most recently active rooms │
│ │
│ 2. Compare each room's lastActivity > touchTime │
│ └─► Identify rooms with new messages │
│ │
│ 3. For changed rooms: GET /messages?roomId=X │
│ └─► Fetch messages from those rooms │
│ │
│ 4. Filter messages by created > touchTime │
│ └─► Only process truly new messages │
│ │
│ 5. Update touchTime to newest message timestamp │
│ │
│ Result: ~99% reduction in API calls vs polling all rooms │
│ │
└─────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ REGISTRATION SEQUENCE │
├────────────────────────────────────────────────────────────────┤
│ │
│ Client Server │
│ │ │ │
│ │ CAP LS 302 │ │
│ │───────────────────────────────────►│ │
│ │ │ │
│ │◄───────────────────────────────────│ │
│ │ CAP * LS :server-time │ │
│ │ │ │
│ │ CAP REQ :server-time │ │
│ │───────────────────────────────────►│ │
│ │ │ │
│ │◄───────────────────────────────────│ │
│ │ CAP * ACK :server-time │ │
│ │ │ │
│ │ PASS token │ │
│ │───────────────────────────────────►│ │
│ │ │ │
│ │ NICK myusername │ │
│ │───────────────────────────────────►│ │
│ │ │ │
│ │ USER myusername 0 * :realname │ │
│ │───────────────────────────────────►│ │
│ │ │ │
│ │ CAP END │ │
│ │───────────────────────────────────►│ │
│ │ │ │
│ │ [Server validates PASS token] │ │
│ │ [Fetches rooms from Webex] │ │
│ │ │ │
│ │◄───────────────────────────────────│ │
│ │ 001-005 Welcome messages │ │
│ │ 375-376 MOTD (Webex ASCII logo) │ │
│ │ │ │
│ │
└────────────────────────────────────────────────────────────────┘
Supported IRC numerics:
Registration:
001 RPL_WELCOME Welcome message
002 RPL_YOURHOST Host info
003 RPL_CREATED Server creation
004 RPL_MYINFO Server info
005 RPL_ISUPPORT Supported features (CHANTYPES=#)
251 RPL_LUSERCLIENT User count
MOTD:
375 RPL_MOTDSTART MOTD start
372 RPL_MOTD MOTD content (Webex ASCII logo)
376 RPL_ENDOFMOTD MOTD end
Channel:
321 RPL_LISTSTART LIST header
322 RPL_LIST Channel entry
323 RPL_LISTEND LIST end
324 RPL_CHANNELMODEIS Channel modes (+nt)
332 RPL_TOPIC Channel topic
353 RPL_NAMREPLY NAMES list (chunked for large rooms)
366 RPL_ENDOFNAMES NAMES end
368 RPL_ENDOFBANLIST Ban list end
347 RPL_ENDOFINVITELIST Invite list end
349 RPL_ENDOFEXCEPTLIST Exception list end
User:
302 RPL_USERHOST USERHOST reply
303 RPL_ISON ISON reply
311 RPL_WHOISUSER WHOIS user info
312 RPL_WHOISSERVER WHOIS server
318 RPL_ENDOFWHOIS WHOIS end
315 RPL_ENDOFWHO WHO end
352 RPL_WHOREPLY WHO entry (with H/G flags)
Server:
219 RPL_ENDOFSTATS Stats end
249 RPL_STATSDEBUG Stats debug line
254 RPL_LUSERCHANNELS Channel count
351 RPL_VERSION Server version
391 RPL_TIME Server time
Errors:
451 ERR_NOTREGISTERED Not registered
461 ERR_NEEDMOREPARAMS Need more parameters
464 ERR_PASSWDMISMATCH Password incorrect
IRCv3:
CAP LS/REQ/ACK/END Capability negotiation
server-time Message timestamps (@time=...)
webexjrcd includes a complete JSON encoder/decoder written in pure J:
NB. Decode JSON string to J data structure
dec_ejson_ '{"name":"alice","count":42,"active":true}'
┌──────┬───────┐
│name │alice │
├──────┼───────┤
│count │42 │
├──────┼───────┤
│active│json_tru│
└──────┴───────┘
NB. Encode J data structure to JSON
enc_ejson_ 2 2 $ 'roomId';'abc123';'text';'hello'
{"roomId":"abc123","text":"hello"}
NB. Select field from decoded JSON
'name' select_ejson_ dec_ejson_ '{"name":"alice"}'
┌─────┐
│alice│
└─────┘
NB. Select multiple fields as columns
('name';'count') selector_ejson_ dec_ejson_ '[{"name":"a","count":1},{"name":"b","count":2}]'
┌─┬─┐
│a│1│
├─┼─┤
│b│2│
└─┴─┘The JSON library handles: - Objects, arrays, strings, numbers,
booleans, null - Nested structures - Escape sequences (\n,
\t, \", \\, etc.) - Unicode
escape sequences (stripped for IRC compatibility)
Full OAuth 2.0 authorization code flow implemented in J:
startOAuth =: 3 : 0
'clientId clientSecret sock nick' =. y
NB. Generate 32-char random state for CSRF protection
state =. randomString 32
NB. Build authorization URL
authUrl =. 'https://webexapis.com/v1/authorize'
authUrl =. authUrl,'?client_id=',clientId
authUrl =. authUrl,'&response_type=code'
authUrl =. authUrl,'&redirect_uri=http://localhost:',":oauthCallbackPort,'/callback'
authUrl =. authUrl,'&scope=spark:all'
authUrl =. authUrl,'&state=',state
authUrl ; state ; oauthCallbackPort ; clientId ; clientSecret ; nick
)
startOAuthServer =: 4 : 0
NB. x = port, y = state;clientId;clientSecret;sock;nick
NB. Create TCP listening socket
'rc lsock' =. sdsocket 2 1 0
sdsetsockopt lsock ; SOL_SOCKET ; SO_REUSEADDR ; 1
sdbind lsock ; 2 ; '0.0.0.0' ; port
sdlisten lsock , 5
NB. Wait up to 3 minutes for browser callback
NB. Uses sdselect with 2-second timeout for non-blocking check
NB. When connection arrives:
NB. 1. Parse HTTP GET /callback?code=XXX&state=YYY
NB. 2. Verify state matches (CSRF protection)
NB. 3. Exchange code for tokens via POST to /access_token
NB. 4. Send styled HTML response to browser
NB. 5. Return refresh token
)
exchangeCodeForTokens =: 4 : 0
NB. x = authorization code
NB. y = clientId;clientSecret;port
NB. POST to https://webexapis.com/v1/access_token
NB. Content-Type: application/x-www-form-urlencoded
NB. Body: grant_type=authorization_code&code=X&client_id=Y&...
NB. Returns: access_token and refresh_token
)Non-blocking I/O using J’s jsocket addon:
NB. Main client loop uses sdselect for non-blocking reads
while. connected do.
NB. Check for data with 100ms timeout
selectResult =. sdselect csock ; '' ; '' ; 0.1
if. no data available do.
6!:3 ] 1 NB. Sleep 1 second to prevent CPU spinning
else.
'rc msg' =. sdrecv csock , 4096 , 0
NB. Process received data...
end.
NB. Poll Webex every 5 seconds
if. (currentTime - lastPollTime) > 5 do.
newMsgs =. allNewMessages''
NB. Forward to client...
end.
end.Webex room titles are cleaned for IRC compatibility:
ircCleanChan =: 3 : 0
name =. stripUnicodeEscapes y NB. Remove \uXXXX sequences
name =. name rplc ' ';'_' NB. Spaces → underscores
name =. name rplc ':';';' NB. Colons → semicolons (: is special in IRC)
name =. name -. ',',CR,LF,(0 7{a.) NB. Remove invalid chars
if. '#' ~: {. name do.
name =. '#',name NB. Ensure # prefix
end.
if. 64 < # name do.
NB. Truncate long names, append room ID suffix for uniqueness
suffix =. _3 {. roomId
name =. (60 {. name), ';', suffix
end.
name
)
NB. Examples:
ircCleanChan 'Engineering Team' → '#Engineering_Team'
ircCleanChan 'Q&A: Ask Me Anything' → '#Q&A;_Ask_Me_Anything'
ircCleanChan 'Very Long Room Name...' → '#Very_Long_Room_Name...;x2F'Edit the top of webexjrcd.ijs:
ircPort =: 6661 NB. IRC server port (default: 6661, standard: 6667)
oauthCallbackPort =: 8667 NB. OAuth redirect port (must match Webex integration)
debugEnable =: 1 NB. Debug output (WARNING: shows auth tokens!)
ioEnable =: 1 NB. Show raw IRC protocol linesSecurity Note: Set debugEnable =: 0 in
production as debug output includes authentication tokens.
You must provide a PASS command before
NICK/USER. Configure your IRC client to send
the server password on connect.
Your IRC nickname must exactly match your Webex username (the part
before @ in your email). The server verifies this by
querying /people/me with your token.
clientId:clientSecret to re-authorize
via OAuthYou have 3 minutes to complete browser authorization. If it times
out: 1. Disconnect from the IRC server 2. Reconnect with the same
PASS to restart OAuth
/joined;log to fetch historical messagesChange ircPort in the configuration section, or find and
stop the process using port 6661:
lsof -i :6661If you see errors about rate limits, the bridge automatically sleeps between polls. With delta polling, you should stay well under Webex limits even with 1000+ rooms.
| Aspect | Details |
|---|---|
| Language | J (jsoftware.com) |
| Lines of Code | ~3600 |
| External Dependencies | None (pure J + standard library) |
| IRC Protocol | RFC 1459 + IRCv3 server-time |
| Webex API | REST v1 (webexapis.com) |
| Max Rooms | 1000+ (tested) |
| Max Members/Room | 1000+ (paginated via Link header) |
| Polling Interval | 5 seconds |
| OAuth Timeout | 3 minutes |
| Token Lifetimes | Bearer ~12h, Refresh ~90d |
| Socket Timeout | 100ms (non-blocking) |
| Idle CPU Usage | ~0% |
* Connecting to localhost:6661...
* Connected. Now logging in.
-!- NOTICE: Fetching all rooms...
-!- NOTICE: Found 247 rooms
-!- Welcome to the webexjrcd Webex IRC Bridge
-!-
-!- ..';::::::;,'. .,cldddddddol;'
-!- .':::;:::::::::::::;. .:ddddddddddxxxxxxxo,
-!- [ASCII Webex Logo]
-!-
-!- IRC to Webex Bridge
-!-
/list
#Engineering_Team 1 :Engineering Team :: Y2lzY29zcGFyazov...
#General_Chat 1 :General Chat :: Y2lzY29zcGFyazov...
#Cats_Cats_Cats 1 :Cats Cats Cats :: Y2lzY29zcGFyazov...
...
/join #Engineering_Team
-!- byakuren [~user@webex] has joined #Engineering_Team
-!- Topic for #Engineering_Team: Engineering Team :: Y2lzY29...
-!- Channel #Engineering_Team: 42 nicks
;log 5
<alice> [x2F] Has anyone seen the new API docs?
<bob> <x2F| [g7H] Yes! They look great
<carol> <x2F| [k9J] Agreed, much clearer now
<dave> [m3P] Unrelated: meeting in 10 mins
<eve> [n4Q] Thanks for the reminder
;r x2F I'll review them this afternoon
<byakuren> <x2F| [p5R] I'll review them this afternoon
s/this afternoon/today/
-!- byakuren: I'll review them today
/me grabs coffee
* byakuren grabs coffee
/quit
* Disconnected
Written in J. Use freely.
webexjrcd - IRC to Webex Bridge