From 49192e6fa51e2056d676281062351168ab443012 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 19 Apr 2026 21:58:29 -0700 Subject: [PATCH] add /v1/whois to API (#2387) --- docs/API.md | 24 ++++++++++++++++ irc/api.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ irc/channel.go | 4 +-- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index bfc9bc47..d8b88238 100644 --- a/docs/API.md +++ b/docs/API.md @@ -179,3 +179,27 @@ The response is a JSON object with fields: * `max`: maximum number of users seen connected at once * `channels`: integer, number of channels currently active * `servers`: integer, number of servers connected in the network + +`/v1/whois` +----------- + +This endpoint returns data about the current status of a nickname on the server. The request is a JSON object with fields: + +* `nickname`: string, nickname to query + +The response is a JSON object with fields: + +* `success`: whether the request succeeded (a successful execution returns `true` here even if the nickname is not present) +* `present`: whether the nickname is present on the server; if false, the remaining fields are undefined +* `nickname`: actual nickname (without case normalization) of the nickname as present on the server +* `username`: IRC protocol username field of the user (not to be confused with account name) +* `hostname`: hostname of the user +* `realname`: realname/gecos of the user +* `account`: account name of the user (without case normalization) +* `modes`: string of all set user modes +* `away`: user's away message if set (omitted if they are not away) +* `channels`: list of channels the user is present in. Each channel is an object with fields: + * `name`: name of the channel + * `mode`: string, highest mode the user has in the channel (omitted if they have no mode) + * `join_time`: string, time the user joined the channel (in ISO8601 format) +* `session_count`: integer, number of active sessions diff --git a/irc/api.go b/irc/api.go index 0a0bfe96..a91ccfc2 100644 --- a/irc/api.go +++ b/irc/api.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "runtime" + "slices" "strings" "github.com/ergochat/ergo/irc/modes" @@ -27,6 +28,7 @@ func newAPIHandler(server *Server) http.Handler { // use Ergo as a source of truth for authentication in other services: api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth) + api.mux.HandleFunc("POST /v1/whois", api.handleWhois) // legacy names for /v1/ns endpoints: api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister) @@ -422,6 +424,81 @@ type apiListResponse struct { Channels []apiChannelData `json:"channels"` } +type apiWhoisRequest struct { + Nickname string `json:"nickname"` +} + +type apiWhoisChannelData struct { + Name string `json:"name"` + Mode string `json:"mode,omitempty"` + JoinTime string `json:"join_time"` +} + +type apiWhoisResponse struct { + apiGenericResponse + Present bool `json:"present"` + Nickname string `json:"nickname,omitempty"` + Username string `json:"username,omitempty"` + Hostname string `json:"hostname,omitempty"` + Realname string `json:"realname,omitempty"` + Account string `json:"account"` + Modes string `json:"modes,omitempty"` + Away string `json:"away,omitempty"` + Channels []apiWhoisChannelData `json:"channels"` + SessionCount int `json:"session_count"` +} + +func (a *ergoAPI) handleWhois(w http.ResponseWriter, r *http.Request) { + var request apiWhoisRequest + if err := a.decodeJSONRequest(&request, w, r); err != nil { + return + } + + response := apiWhoisResponse{ + apiGenericResponse: apiGenericResponse{Success: true}, + } + + client := a.server.clients.Get(request.Nickname) + if client != nil { + response.Present = true + details := client.Details() + response.Nickname = details.nick + response.Username = details.username + response.Hostname = details.hostname + response.Realname = details.realname + if details.account != "" { + response.Account = details.accountName + } + response.Modes = client.ModeString() + if away, awayMsg := client.Away(); away { + response.Away = awayMsg + } + response.SessionCount = len(client.Sessions()) + + channels := client.Channels() + response.Channels = make([]apiWhoisChannelData, 0, len(channels)) + for _, channel := range channels { + present, joinTime, cModes := channel.ClientStatus(client) + if !present { + continue + } + chData := apiWhoisChannelData{ + Name: channel.Name(), + JoinTime: joinTime.Format(utils.IRCv3TimestampFormat), + } + for _, m := range modes.ChannelUserModes { + if slices.Contains(cModes, m) { + chData.Mode = string(rune(m)) + break + } + } + response.Channels = append(response.Channels, chData) + } + } + + a.writeJSONResponse(response, w, r) +} + func (a *ergoAPI) handleList(w http.ResponseWriter, r *http.Request) { channels := a.server.channels.ListableChannels() response := apiListResponse{ diff --git a/irc/channel.go b/irc/channel.go index 60ed4a52..57963734 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -547,12 +547,12 @@ func (channel *Channel) ClientPrefixes(client *Client, isMultiPrefix bool) strin } } -func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs int64, cModes modes.Modes) { +func (channel *Channel) ClientStatus(client *Client) (present bool, joinTime time.Time, cModes modes.Modes) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() memberData, present := channel.members[client] if present { - return present, time.Unix(0, memberData.joinTime).Unix(), memberData.modes.AllModes() + return present, time.Unix(0, memberData.joinTime), memberData.modes.AllModes() } else { return }