Mike 11 часов назад
Родитель
Сommit
7db9dc9bd8

+ 2 - 0
cmd/server/factory.go

@@ -600,6 +600,8 @@ func WebAPI(deps Container) *webapi.Server {
 		// Phase 5 additions for buddy list and messaging
 		BuddyListManager:   buddyListManager,
 		ChatSessionManager: deps.chatSessionManager,
+		ChatService:        foodgroup.NewChatService(deps.chatSessionManager),
+		ChatNavService:     foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
 		RecalcWarning:      deps.icbmSvc.RestoreWarningLevel,
 		LowerWarnLevel:     deps.icbmSvc.UpdateWarnLevel,
 		FeedbagService:     deps.feedbagSvc,

+ 2 - 0
server/webapi/handler.go

@@ -26,6 +26,8 @@ type Handler struct {
 	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
 	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
 	ChatSessionManager ChatSessionManager
+	ChatService        ChatService
+	ChatNavService     ChatNavService
 	FeedbagService     FeedbagService
 	DirSearchService   DirSearchService
 	IconSource         handlers.BuddyIconSource

+ 30 - 0
server/webapi/handlers/buddy_list_manager.go

@@ -38,6 +38,10 @@ type WebAPIBuddyGroup struct {
 	Buddies []WebAPIBuddyInfo `json:"buddies"`
 	Recent  bool              `json:"recent,omitempty"`
 	Smart   interface{}       `json:"smart,omitempty"` // Can be null or number
+	// Imserv marks this group as a group-chat group. The client routes any
+	// buddylist group carrying imserv (and containing an imserv buddy) into its
+	// "Group chats" section instead of "Contacts".
+	Imserv string `json:"imserv,omitempty"`
 }
 
 // WebAPIBuddyInfo represents a buddy in the WebAPI format.
@@ -57,6 +61,10 @@ type WebAPIBuddyInfo struct {
 	BuddyIcon    string   `json:"buddyIcon,omitempty"`
 	Capabilities []string `json:"capabilities,omitempty"`
 	MemberSince  int64    `json:"memberSince,omitempty"`
+	// Imserv, when set, makes the client treat this entry as a group-chat room
+	// (its aa() predicate) rather than a user; MemberCounts is the room's size.
+	Imserv       string `json:"imserv,omitempty"`
+	MemberCounts int    `json:"memberCounts,omitempty"`
 }
 
 // GetBuddyListForUser retrieves and converts the buddy list for a user.
@@ -169,6 +177,28 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.
 	return out, nil
 }
 
+// CannedGroupChatGroup returns a fake group-chat group for the buddylist feed, so
+// we can confirm the buddylist event drives the client's "Group chats" section.
+// The client routes a group there when the GROUP carries imserv and it contains
+// an imserv buddy.
+// TODO(groupchat): replace with the user's persisted joined rooms.
+func CannedGroupChatGroup() WebAPIBuddyGroup {
+	return WebAPIBuddyGroup{
+		Name:   "Canned Group Chat",
+		Imserv: "4-0-Canned Group Chat",
+		Buddies: []WebAPIBuddyInfo{{
+			AimID:        "4-0-Canned Group Chat",
+			Imserv:       "4-0-Canned Group Chat",
+			Friendly:     "Canned Group Chat",
+			DisplayID:    "Canned Group Chat",
+			MemberCounts: 1,
+			State:        "online",
+			UserType:     "aim",
+			Service:      "AIM",
+		}},
+	}
+}
+
 // getBuddyInfo retrieves a buddy's current presence by issuing a locate
 // UserInfoQuery on behalf of the requesting session's OSCAR instance.
 func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.SessionInstance, baseURL string, buddyName string) WebAPIBuddyInfo {

+ 484 - 0
server/webapi/handlers/imserv.go

@@ -0,0 +1,484 @@
+package handlers
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mk6i/open-oscar-server/config"
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// roomNameMaxLen bounds the room name, matching the client's 45-char truncation
+// of the friendly field on imserv/create.
+const roomNameMaxLen = 45
+
+// ChatNavService creates chat rooms and serves room metadata for the imserv
+// endpoints. Backed by foodgroup.ChatNavService (the same instance BOS and TOC
+// use), so web-created rooms are real OSCAR rooms.
+type ChatNavService interface {
+	CreateRoom(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)
+	RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)
+}
+
+// ChatBridgeOServiceService issues the OSCAR service handoff that joining a chat
+// room requires: ServiceRequest yields the chat login cookie, and ClientOnline
+// brings the chat session online (which triggers the member list and arrival
+// announcement).
+type ChatBridgeOServiceService interface {
+	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)
+	ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
+}
+
+// ChatParticipants lists the current participants of a chat room, keyed by the
+// room cookie. Backed by the shared chat session manager (the ChatMessageRelayer).
+type ChatParticipants interface {
+	AllSessions(chatCookie string) []*state.Session
+}
+
+// ChatAuthService registers the dedicated chat SessionInstance a room join needs.
+// CrackCookie decodes the chat login cookie from ServiceRequest; RegisterChatSession
+// creates the chat session; SignoutChat is its close hook.
+type ChatAuthService interface {
+	CrackCookie(authCookie []byte) (state.ServerCookie, error)
+	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error)
+	SignoutChat(ctx context.Context, sess *state.Session)
+}
+
+// ImservHandler serves the Web AIM group-chat (imserv) endpoints by bridging them
+// onto the OSCAR chat food group. It mirrors the TOC chat bridge
+// (server/toc/cmd_client.go ChatJoin/ChatLeave): each joined room gets a
+// dedicated chat SessionInstance registered on the WebAPISession, and relayed
+// chat SNACs surface through that session's event queue.
+type ImservHandler struct {
+	SessionManager  *state.WebAPISessionManager
+	ChatNavService  ChatNavService
+	OServiceService ChatBridgeOServiceService
+	AuthService     ChatAuthService
+	ICBMService     ICBMService
+	Participants    ChatParticipants
+	Logger          *slog.Logger
+}
+
+// Create handles GET/POST imserv/create. It creates (exchange 4) a user room
+// named by the friendly param and returns the room id (the ChatRoom cookie) the
+// client will use to join and to address messages. It does not auto-join; the
+// client calls imserv/join separately.
+func (h *ImservHandler) Create(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	ctx := r.Context()
+
+	name := queryOrFormParam(r, "friendly")
+	if name == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: friendly")
+		return
+	}
+	if len(name) > roomNameMaxLen {
+		name = name[:roomNameMaxLen]
+	}
+
+	inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+		Exchange: state.PrivateExchange,
+		Cookie:   "create",
+		TLVBlock: wire.TLVBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ChatRoomTLVRoomName, name),
+			},
+		},
+	}
+
+	reply, err := h.ChatNavService.CreateRoom(ctx, sess.OSCARSession, wire.SNACFrame{}, inBody)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to create chat room", "err", err.Error())
+		SendError(w, http.StatusInternalServerError, "failed to create chat room")
+		return
+	}
+
+	room, ok := roomInfoFromNavReply(reply)
+	if !ok {
+		h.Logger.ErrorContext(ctx, "create room reply missing room info")
+		SendError(w, http.StatusInternalServerError, "failed to create chat room")
+		return
+	}
+
+	// Auto-join the creator: the web client's "start group chat" flow creates a
+	// room and immediately sends to it without a separate join, so the creator
+	// must already be a participant or im/sendIM would fall through to the 1:1
+	// path. joinRoom is idempotent, so a client that does call join later is fine.
+	if err := h.joinRoom(ctx, sess, room.Cookie); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to auto-join created room", "room", room.Cookie, "err", err.Error())
+		SendError(w, http.StatusInternalServerError, "failed to join created chat room")
+		return
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = roomData(room.Cookie, name)
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.InfoContext(ctx, "chat room created and joined",
+		"screenName", sess.ScreenName.String(),
+		"room", room.Cookie,
+		"name", name,
+	)
+}
+
+// Join handles GET/POST imserv/join. It runs the OSCAR chat-join handoff for the
+// room named by the imserv param, registers the resulting chat session on the web
+// session, and starts a listener so relayed room messages surface via fetchEvents.
+func (h *ImservHandler) Join(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	ctx := r.Context()
+
+	roomID := queryOrFormParam(r, "imserv")
+	if roomID == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
+		return
+	}
+
+	// Already joined: return the room info without a second handoff.
+	if _, joined := sess.ChatRoom(roomID); joined {
+		h.sendJoinedResponse(w, r, roomID, "")
+		return
+	}
+
+	// Resolve the room by cookie to confirm it exists and read its name. MVP
+	// supports user-created rooms only (exchange 4).
+	roomReply, err := h.ChatNavService.RequestRoomInfo(ctx, wire.SNACFrame{},
+		wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+			Cookie:   roomID,
+			Exchange: state.PrivateExchange,
+		})
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to look up chat room", "room", roomID, "err", err.Error())
+		SendError(w, http.StatusNotFound, "chat room not found")
+		return
+	}
+	room, ok := roomInfoFromNavReply(roomReply)
+	if !ok {
+		SendError(w, http.StatusNotFound, "chat room not found")
+		return
+	}
+
+	if err := h.joinRoom(ctx, sess, room.Cookie); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to join chat room", "room", roomID, "err", err.Error())
+		SendError(w, http.StatusInternalServerError, "failed to join chat room")
+		return
+	}
+
+	h.sendJoinedResponse(w, r, room.Cookie, room.Name)
+
+	h.Logger.InfoContext(ctx, "chat room joined",
+		"screenName", sess.ScreenName.String(),
+		"room", room.Cookie,
+	)
+}
+
+// joinRoom runs the OSCAR chat handoff for a room cookie, registers the room's
+// chat session on the web session, and starts its listener so relayed room
+// messages surface via fetchEvents. It is idempotent: joining an
+// already-joined room is a no-op. Shared by Create (auto-join the creator) and
+// Join. Mirrors the tail of TOC's ChatJoin (server/toc/cmd_client.go).
+func (h *ImservHandler) joinRoom(ctx context.Context, sess *state.WebAPISession, cookie string) error {
+	if _, joined := sess.ChatRoom(cookie); joined {
+		return nil
+	}
+
+	chatSess, err := h.registerChatSession(ctx, sess, cookie)
+	if err != nil {
+		return err
+	}
+
+	// Register before ClientOnline so the arrival announcement and member list the
+	// server sends in response are captured by the listener.
+	if !sess.AddChatRoom(cookie, chatSess) {
+		// Session is tearing down; undo the chat session we just created.
+		chatSess.CloseInstance()
+		return errors.New("session ended")
+	}
+	sess.StartListeningToChatSession(cookie, chatSess)
+
+	if err := h.OServiceService.ClientOnline(ctx, wire.Chat, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
+		if inst, removed := sess.RemoveChatRoom(cookie); removed {
+			inst.CloseInstance()
+		}
+		return err
+	}
+	return nil
+}
+
+// Leave handles imserv/delete (and any explicit leave): it closes the room's chat
+// session, which announces the departure and unwinds its listener.
+func (h *ImservHandler) Leave(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	roomID := queryOrFormParam(r, "imserv")
+	if roomID == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
+		return
+	}
+
+	if inst, removed := sess.RemoveChatRoom(roomID); removed {
+		inst.CloseInstance()
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// Invite handles imserv/invite: it sends a chat-room invitation to the buddy
+// named by t. The invite is an ICBM channel-2 CapChat rendezvous carrying the
+// room info, exactly like TOC's ChatInvite (server/toc/cmd_client.go). The
+// recipient's client surfaces it as an `im` event with specialData.invitation.
+func (h *ImservHandler) Invite(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	ctx := r.Context()
+
+	roomID := queryOrFormParam(r, "imserv")
+	invitee := queryOrFormParam(r, "t")
+	if roomID == "" || invitee == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: imserv and t")
+		return
+	}
+
+	exchange, instance, name, ok := parseRoomCookie(roomID)
+	if !ok {
+		SendError(w, http.StatusBadRequest, "invalid imserv room id")
+		return
+	}
+
+	roomInfo := wire.ICBMRoomInfo{Exchange: exchange, Cookie: roomID, Instance: instance}
+	prompt := fmt.Sprintf("%s would like you to join the chat room %q", sess.ScreenName.String(), name)
+
+	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		ChannelID:  wire.ICBMChannelRendezvous,
+		ScreenName: invitee,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
+					Type:       wire.ICBMRdvMessagePropose,
+					Capability: wire.CapChat,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMRdvTLVTagsSeqNum, uint16(1)),
+							wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, prompt),
+							wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMECharset, "us-ascii"),
+							wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMELang, "en"),
+							wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, roomInfo),
+						},
+					},
+				}),
+			},
+		},
+	}
+
+	if _, err := h.ICBMService.ChannelMsgToHost(ctx, sess.OSCARSession, wire.SNACFrame{}, snac); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to send chat invite", "room", roomID, "invitee", invitee, "err", err.Error())
+		SendError(w, http.StatusInternalServerError, "failed to send invite")
+		return
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.InfoContext(ctx, "chat invite sent",
+		"screenName", sess.ScreenName.String(), "room", roomID, "invitee", invitee)
+}
+
+// GetMembers handles imserv/getMembers: it returns the room's current
+// participants. The client reads data.members (each {member, memberType}) to
+// populate the roster, excluding memberType "invite".
+func (h *ImservHandler) GetMembers(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	roomID := queryOrFormParam(r, "imserv")
+	if roomID == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
+		return
+	}
+
+	members := []map[string]interface{}{}
+	for _, participant := range h.Participants.AllSessions(roomID) {
+		members = append(members, map[string]interface{}{
+			"member":     participant.IdentScreenName().String(),
+			"memberType": "member",
+		})
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{"members": members}
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// GetSettings handles imserv/getSettings: it returns a room's metadata. The
+// client blocks a group-chat entry's rendering (a permanent loading spinner)
+// until this returns 200 — its loader only flips "loaded" on a successful
+// getSettings. The client reads data.friendly (name; falls back to the raw id)
+// and data.memberCounts (used when the live roster is empty).
+func (h *ImservHandler) GetSettings(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	roomID := queryOrFormParam(r, "imserv")
+	if roomID == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
+		return
+	}
+
+	name := roomID
+	if _, _, parsed, ok := parseRoomCookie(roomID); ok {
+		name = parsed
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"imserv":       roomID,
+		"friendly":     name,
+		"memberCounts": len(h.Participants.AllSessions(roomID)),
+	}
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// GetRecentActivity handles imserv/getRecentActivity: room message history. Not
+// on the render-blocking path (a 404 is tolerated), but implemented to avoid
+// noise. Room lines are not persisted yet, so this returns an empty activity list.
+func (h *ImservHandler) GetRecentActivity(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	roomID := queryOrFormParam(r, "imserv")
+	if roomID == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
+		return
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"imservActivities": []map[string]interface{}{
+			{"imserv": roomID, "activities": []map[string]interface{}{}},
+		},
+	}
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// Reject handles imserv/reject: the invitee declines an invitation. For MVP this
+// is acknowledged without notifying the inviter.
+func (h *ImservHandler) Reject(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// parseRoomCookie splits a chat room cookie of the form
+// "<exchange>-<instance>-<name>" into its parts. The name may contain "-", so
+// only the first two segments are parsed off.
+func parseRoomCookie(cookie string) (exchange, instance uint16, name string, ok bool) {
+	parts := strings.SplitN(cookie, "-", 3)
+	if len(parts) < 3 {
+		return 0, 0, "", false
+	}
+	ex, err := strconv.ParseUint(parts[0], 10, 16)
+	if err != nil {
+		return 0, 0, "", false
+	}
+	inst, err := strconv.ParseUint(parts[1], 10, 16)
+	if err != nil {
+		return 0, 0, "", false
+	}
+	return uint16(ex), uint16(inst), parts[2], true
+}
+
+// registerChatSession runs the OSCAR chat handoff for a room cookie and returns
+// the newly created chat SessionInstance. Mirrors the tail of TOC's ChatJoin
+// (server/toc/cmd_client.go): ServiceRequest -> CrackCookie -> RegisterChatSession.
+func (h *ImservHandler) registerChatSession(ctx context.Context, sess *state.WebAPISession, cookie string) (*state.SessionInstance, error) {
+	svcReq := wire.SNAC_0x01_0x04_OServiceServiceRequest{
+		FoodGroup: wire.Chat,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{Cookie: cookie}),
+			},
+		},
+	}
+	svcReply, err := h.OServiceService.ServiceRequest(ctx, wire.BOS, sess.OSCARSession, wire.SNACFrame{}, svcReq, config.Listener{})
+	if err != nil {
+		return nil, err
+	}
+	svcBody, ok := svcReply.Body.(wire.SNAC_0x01_0x05_OServiceServiceResponse)
+	if !ok {
+		return nil, errors.New("unexpected ServiceRequest response type")
+	}
+	loginCookie, ok := svcBody.Bytes(wire.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, errors.New("ServiceRequest response missing login cookie")
+	}
+
+	serverCookie, err := h.AuthService.CrackCookie(loginCookie)
+	if err != nil {
+		return nil, err
+	}
+
+	sessCfg := func(s *state.Session) {
+		s.OnSessionClose(func() {
+			ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+			defer cancel()
+			h.AuthService.SignoutChat(ctx, s)
+		})
+	}
+	return h.AuthService.RegisterChatSession(ctx, serverCookie, sessCfg)
+}
+
+// sendJoinedResponse writes a successful join envelope carrying the room id.
+func (h *ImservHandler) sendJoinedResponse(w http.ResponseWriter, r *http.Request, roomID, name string) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = roomData(roomID, name)
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// roomInfo carries the fields the imserv bridge needs from a chat room.
+type roomInfo struct {
+	Cookie string
+	Name   string
+}
+
+// roomInfoFromNavReply extracts the room cookie and name from a ChatNav NavInfo
+// reply (the room info TLV wraps a ChatRoomInfoUpdate).
+func roomInfoFromNavReply(reply wire.SNACMessage) (roomInfo, bool) {
+	body, ok := reply.Body.(wire.SNAC_0x0D_0x09_ChatNavNavInfo)
+	if !ok {
+		return roomInfo{}, false
+	}
+	buf, ok := body.Bytes(wire.ChatNavTLVRoomInfo)
+	if !ok {
+		return roomInfo{}, false
+	}
+	var room wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
+	if err := wire.UnmarshalBE(&room, bytes.NewReader(buf)); err != nil {
+		return roomInfo{}, false
+	}
+	name, _ := room.String(wire.ChatRoomTLVRoomName)
+	return roomInfo{Cookie: room.Cookie, Name: name}, true
+}
+
+// roomData builds the room object the client reads from create/join responses.
+// imserv is the id it uses as the im/sendIM target. The client reads the room's
+// display name from `group` (create callback `HG`); friendly is included as a
+// harmless alias.
+func roomData(roomID, name string) map[string]interface{} {
+	data := map[string]interface{}{
+		"imserv": roomID,
+	}
+	if name != "" {
+		data["group"] = name
+		data["friendly"] = name
+	}
+	return data
+}

+ 378 - 0
server/webapi/handlers/imserv_test.go

@@ -0,0 +1,378 @@
+package handlers
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/google/uuid"
+
+	"github.com/mk6i/open-oscar-server/config"
+	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// MockChatService is a mock implementation of ChatService.
+type MockChatService struct {
+	mock.Mock
+}
+
+func (m *MockChatService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	msg, _ := args.Get(0).(*wire.SNACMessage)
+	return msg, args.Error(1)
+}
+
+// MockChatNavService is a mock implementation of ChatNavService.
+type MockChatNavService struct {
+	mock.Mock
+}
+
+func (m *MockChatNavService) CreateRoom(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+func (m *MockChatNavService) RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error) {
+	args := m.Called(ctx, inFrame, inBody)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+// MockChatBridgeOServiceService is a mock implementation of ChatBridgeOServiceService.
+type MockChatBridgeOServiceService struct {
+	mock.Mock
+}
+
+func (m *MockChatBridgeOServiceService) ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error) {
+	args := m.Called(ctx, service, instance, inFrame, inBody, listener)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+func (m *MockChatBridgeOServiceService) ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error {
+	args := m.Called(ctx, service, inBody, instance)
+	return args.Error(0)
+}
+
+// MockChatAuthService is a mock implementation of ChatAuthService.
+type MockChatAuthService struct {
+	mock.Mock
+}
+
+func (m *MockChatAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
+	args := m.Called(authCookie)
+	return args.Get(0).(state.ServerCookie), args.Error(1)
+}
+
+func (m *MockChatAuthService) RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error) {
+	args := m.Called(ctx, authCookie, cfg)
+	inst, _ := args.Get(0).(*state.SessionInstance)
+	return inst, args.Error(1)
+}
+
+func (m *MockChatAuthService) SignoutChat(ctx context.Context, sess *state.Session) {
+	m.Called(ctx, sess)
+}
+
+// navInfoReply builds a ChatNav NavInfo reply carrying a room's cookie and name,
+// matching what CreateRoom/RequestRoomInfo return.
+func navInfoReply(cookie, name string) wire.SNACMessage {
+	return wire.SNACMessage{
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.ChatNavTLVRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Cookie: cookie,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ChatRoomTLVRoomName, name),
+							},
+						},
+					}),
+				},
+			},
+		},
+	}
+}
+
+// A room is addressed like a buddy: im/sendIM to a joined room id must relay
+// through ChatService (not ICBM) and echo the sender's own line as a room `im`
+// event keyed by the room id.
+func TestMessagingHandler_SendIM_RoutesRoomToChatService(t *testing.T) {
+	const roomID = "room-cookie-123"
+
+	oscarInstance := state.NewSession().AddInstance()
+	mgr := state.NewWebAPISessionManager()
+	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), "test-dev",
+		[]string{"im", "sentIM"}, oscarInstance, "", slog.Default())
+	require.NoError(t, err)
+
+	// The session has joined the room; its chat instance is what ChatService is
+	// invoked against.
+	chatInstance := state.NewSession().AddInstance()
+	require.True(t, session.AddChatRoom(roomID, chatInstance))
+
+	chatService := &MockChatService{}
+	chatService.On("ChannelMsgToHost", mock.Anything, chatInstance, mock.Anything, mock.Anything).
+		Return(nil, nil)
+
+	icbmService := &MockICBMService{} // must NOT be called for a room target
+
+	handler := &MessagingHandler{
+		SessionManager: mgr,
+		ICBMService:    icbmService,
+		ChatService:    chatService,
+		LocateService:  stubLocateService("Ann Dupree"),
+		FeedbagService: &MockFeedbagService{},
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET",
+		"/im/sendIM?aimsid="+session.AimSID+"&t="+url.QueryEscape(roomID)+"&message=hi", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(mgr, handler.SendIM).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	chatService.AssertCalled(t, "ChannelMsgToHost", mock.Anything, chatInstance, mock.Anything, mock.Anything)
+	icbmService.AssertNotCalled(t, "ChannelMsgToHost", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+
+	// The sender's own line is echoed as a room `im` event.
+	var room types.RoomIMEvent
+	var found bool
+	for _, e := range session.EventQueue.GetAllEvents() {
+		if e.Type == types.EventTypeIM {
+			room, found = e.Data.(types.RoomIMEvent)
+		}
+	}
+	require.True(t, found, "expected a room im event")
+	assert.Equal(t, roomID, room.Imserv)
+	assert.Equal(t, roomID, room.Source.AimID)
+	assert.Equal(t, "imservMsg", room.SpecialIM)
+	assert.Equal(t, "anndupree", room.SpecialData.ImFromImserv.OrigSender)
+	assert.Equal(t, "hi", room.SpecialData.ImFromImserv.Text)
+}
+
+// stubChatJoinDeps wires the ServiceRequest/CrackCookie/RegisterChatSession/
+// ClientOnline handoff mocks for the room-join path, returning the chat instance
+// RegisterChatSession hands back so tests can assert it was registered.
+func stubChatJoinDeps(oscarInstance *state.SessionInstance) (*MockChatBridgeOServiceService, *MockChatAuthService, *state.SessionInstance) {
+	oservice := &MockChatBridgeOServiceService{}
+	oservice.On("ServiceRequest", mock.Anything, wire.BOS, oscarInstance, mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{
+			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("login-cookie")),
+					},
+				},
+			},
+		}, nil)
+
+	chatInstance := state.NewSession().AddInstance()
+	auth := &MockChatAuthService{}
+	auth.On("CrackCookie", []byte("login-cookie")).Return(state.ServerCookie{}, nil)
+	auth.On("RegisterChatSession", mock.Anything, mock.Anything, mock.Anything).Return(chatInstance, nil)
+	oservice.On("ClientOnline", mock.Anything, wire.Chat, mock.Anything, chatInstance).Return(nil)
+	return oservice, auth, chatInstance
+}
+
+// imserv/create makes an exchange-4 room, auto-joins the creator (so im/sendIM to
+// the room works immediately), and returns the room id as imserv.
+func TestImservHandler_Create(t *testing.T) {
+	const roomID = "created-cookie"
+
+	oscarInstance := state.NewSession().AddInstance()
+	mgr := state.NewWebAPISessionManager()
+	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), "test-dev",
+		[]string{"im"}, oscarInstance, "", slog.Default())
+	require.NoError(t, err)
+
+	chatNav := &MockChatNavService{}
+	chatNav.On("CreateRoom", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Return(navInfoReply(roomID, "My Room"), nil)
+	oservice, auth, chatInstance := stubChatJoinDeps(oscarInstance)
+
+	handler := &ImservHandler{
+		SessionManager:  mgr,
+		ChatNavService:  chatNav,
+		OServiceService: oservice,
+		AuthService:     auth,
+		Logger:          slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET",
+		"/imserv/create?aimsid="+session.AimSID+"&friendly="+url.QueryEscape("My Room"), nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(mgr, handler.Create).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	chatNav.AssertCalled(t, "CreateRoom", mock.Anything, oscarInstance, mock.Anything, mock.Anything)
+	assert.Contains(t, rr.Body.String(), roomID)
+
+	// The creator is auto-joined so subsequent im/sendIM routes to the room.
+	got, joined := session.ChatRoom(roomID)
+	require.True(t, joined)
+	assert.Equal(t, chatInstance, got)
+}
+
+// imserv/join runs the OSCAR chat handoff and registers the room's chat session
+// on the WebAPISession so subsequent im/sendIM routes to it.
+func TestImservHandler_Join(t *testing.T) {
+	const roomID = "join-cookie"
+
+	oscarInstance := state.NewSession().AddInstance()
+	mgr := state.NewWebAPISessionManager()
+	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), "test-dev",
+		[]string{"im"}, oscarInstance, "", slog.Default())
+	require.NoError(t, err)
+
+	chatNav := &MockChatNavService{}
+	chatNav.On("RequestRoomInfo", mock.Anything, mock.Anything, mock.Anything).
+		Return(navInfoReply(roomID, "My Room"), nil)
+
+	oservice := &MockChatBridgeOServiceService{}
+	oservice.On("ServiceRequest", mock.Anything, wire.BOS, oscarInstance, mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{
+			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("login-cookie")),
+					},
+				},
+			},
+		}, nil)
+
+	chatInstance := state.NewSession().AddInstance()
+	auth := &MockChatAuthService{}
+	auth.On("CrackCookie", []byte("login-cookie")).Return(state.ServerCookie{}, nil)
+	auth.On("RegisterChatSession", mock.Anything, mock.Anything, mock.Anything).Return(chatInstance, nil)
+
+	oservice.On("ClientOnline", mock.Anything, wire.Chat, mock.Anything, chatInstance).Return(nil)
+
+	handler := &ImservHandler{
+		SessionManager:  mgr,
+		ChatNavService:  chatNav,
+		OServiceService: oservice,
+		AuthService:     auth,
+		Logger:          slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET",
+		"/imserv/join?aimsid="+session.AimSID+"&imserv="+url.QueryEscape(roomID), nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(mgr, handler.Join).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	// The room's chat session is registered so im/sendIM can find it.
+	got, joined := session.ChatRoom(roomID)
+	require.True(t, joined)
+	assert.Equal(t, chatInstance, got)
+	oservice.AssertCalled(t, "ClientOnline", mock.Anything, wire.Chat, mock.Anything, chatInstance)
+}
+
+// imserv/invite sends a chat-room invitation to the buddy named by t as an ICBM
+// channel-2 CapChat rendezvous carrying the room info.
+func TestImservHandler_Invite(t *testing.T) {
+	const roomID = "4-0-Test Room"
+
+	oscarInstance := state.NewSession().AddInstance()
+	mgr := state.NewWebAPISessionManager()
+	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), "test-dev",
+		[]string{"im"}, oscarInstance, "", slog.Default())
+	require.NoError(t, err)
+
+	var captured wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+	icbm := &MockICBMService{}
+	icbm.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Run(func(args mock.Arguments) {
+			captured = args.Get(3).(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)
+		}).Return(nil, nil)
+
+	handler := &ImservHandler{SessionManager: mgr, ICBMService: icbm, Logger: slog.Default()}
+
+	req, err := http.NewRequest("GET",
+		"/imserv/invite?aimsid="+session.AimSID+"&imserv="+url.QueryEscape(roomID)+"&t=bobsmith", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(mgr, handler.Invite).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	assert.Equal(t, wire.ICBMChannelRendezvous, captured.ChannelID)
+	assert.Equal(t, "bobsmith", captured.ScreenName)
+	// The rendezvous carries a CapChat propose fragment with the room info.
+	rd, ok := captured.Bytes(wire.ICBMTLVData)
+	require.True(t, ok)
+	var frag wire.ICBMCh2Fragment
+	require.NoError(t, wire.UnmarshalBE(&frag, bytes.NewReader(rd)))
+	assert.Equal(t, wire.CapChat, uuid.UUID(frag.Capability))
+	svc, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData)
+	require.True(t, ok)
+	var room wire.ICBMRoomInfo
+	require.NoError(t, wire.UnmarshalBE(&room, bytes.NewReader(svc)))
+	assert.Equal(t, roomID, room.Cookie)
+	assert.Equal(t, uint16(4), room.Exchange)
+}
+
+// MockChatParticipants is a mock implementation of ChatParticipants.
+type MockChatParticipants struct {
+	mock.Mock
+}
+
+func (m *MockChatParticipants) AllSessions(chatCookie string) []*state.Session {
+	args := m.Called(chatCookie)
+	s, _ := args.Get(0).([]*state.Session)
+	return s
+}
+
+// imserv/getSettings must return 200 with the room name (friendly) and member
+// count — the client's group-chat entry stays in a permanent loading spinner
+// until this succeeds.
+func TestImservHandler_GetSettings(t *testing.T) {
+	const roomID = "4-0-Ida and Jon chat"
+
+	oscarInstance := state.NewSession().AddInstance()
+	mgr := state.NewWebAPISessionManager()
+	session, err := mgr.CreateSession(state.DisplayScreenName("Ida Pruitt"), "test-dev",
+		[]string{"im", "conversation"}, oscarInstance, "", slog.Default())
+	require.NoError(t, err)
+
+	participants := &MockChatParticipants{}
+	participants.On("AllSessions", roomID).Return([]*state.Session{state.NewSession()})
+
+	handler := &ImservHandler{SessionManager: mgr, Participants: participants, Logger: slog.Default()}
+
+	req, err := http.NewRequest("GET",
+		"/imserv/getSettings?aimsid="+session.AimSID+"&imserv="+url.QueryEscape(roomID), nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(mgr, handler.GetSettings).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var body struct {
+		Response struct {
+			StatusCode int `json:"statusCode"`
+			Data       struct {
+				Imserv       string `json:"imserv"`
+				Friendly     string `json:"friendly"`
+				MemberCounts int    `json:"memberCounts"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
+	assert.Equal(t, 200, body.Response.StatusCode)
+	assert.Equal(t, roomID, body.Response.Data.Imserv)
+	assert.Equal(t, "Ida and Jon chat", body.Response.Data.Friendly)
+	assert.Equal(t, 1, body.Response.Data.MemberCounts)
+}

+ 78 - 0
server/webapi/handlers/messaging.go

@@ -1,6 +1,7 @@
 package handlers
 
 import (
+	"bytes"
 	"context"
 	"crypto/rand"
 	"encoding/binary"
@@ -20,10 +21,18 @@ type ICBMService interface {
 	ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
 }
 
+// ChatService relays a message to the participants of a chat room. Backed by
+// foodgroup.ChatService. The web client addresses a room like a buddy (via the
+// im/sendIM `t` param), so SendIM routes room targets here.
+type ChatService interface {
+	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)
+}
+
 // MessagingHandler handles Web AIM API messaging endpoints
 type MessagingHandler struct {
 	SessionManager *state.WebAPISessionManager
 	ICBMService    ICBMService
+	ChatService    ChatService
 	LocateService  LocateService
 	FeedbagService FeedbagService
 	Logger         *slog.Logger
@@ -61,6 +70,13 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		return
 	}
 
+	// A room is addressed exactly like a buddy: if t names a room this session has
+	// joined, relay through the chat food group instead of ICBM.
+	if chatInstance, isRoom := sess.ChatRoom(recipient); isRoom {
+		h.sendRoomMessage(w, r, sess, chatInstance, recipient, message)
+		return
+	}
+
 	// Parse optional parameters
 	autoResponse := queryOrFormParam(r, "autoResponse") == "1"
 
@@ -172,6 +188,68 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	SendResponse(w, r, response, h.Logger)
 }
 
+// sendRoomMessage relays a message to a joined chat room's participants via the
+// chat food group, then echoes the sender's own line back to their event queue.
+// Mirrors TOC's ChatSend (server/toc/cmd_client.go): reflection is enabled so the
+// server returns the canonical (transformed) message synchronously; other
+// participants receive it via the relay, which excludes the sender.
+func (h *MessagingHandler) sendRoomMessage(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession, chatInstance *state.SessionInstance, roomID, message string) {
+	ctx := r.Context()
+
+	block := wire.TLVRestBlock{}
+	// TLV order matters for AIM 2.x: out of order, screen names do not appear with
+	// each chat message.
+	block.Append(wire.NewTLVBE(wire.ChatTLVEnableReflectionFlag, uint8(1)))
+	block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, chatInstance.Session().TLVUserInfo()))
+	block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
+	block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+		TLVList: wire.TLVList{
+			wire.NewTLVBE(wire.ChatTLVMessageInfoText, message),
+		},
+	}))
+
+	snac := wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+		Channel:      wire.ICBMChannelMIME,
+		TLVRestBlock: block,
+	}
+	reply, err := h.ChatService.ChannelMsgToHost(ctx, chatInstance, wire.SNACFrame{}, snac)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to send chat message", "room", roomID, "err", err.Error())
+		h.sendErrorResponse(w, http.StatusInternalServerError, "failed to send message")
+		return
+	}
+
+	// Echo the sender's own line. The reflected reply carries the canonical text
+	// and sender; fall back to the raw inputs if reflection returned nothing.
+	senderAimID := sess.ScreenName.IdentScreenName().String()
+	text := message
+	if reply != nil {
+		if body, ok := reply.Body.(wire.SNAC_0x0E_0x06_ChatChannelMsgToClient); ok {
+			if info, ok := body.Bytes(wire.ChatTLVSenderInformation); ok {
+				var userInfo wire.TLVUserInfo
+				if err := wire.UnmarshalBE(&userInfo, bytes.NewReader(info)); err == nil {
+					senderAimID = state.NewIdentScreenName(userInfo.ScreenName).String()
+				}
+			}
+			if msgInfo, ok := body.Bytes(wire.ChatTLVMessageInfo); ok {
+				if reflected, err := wire.UnmarshalChatMessageText(msgInfo); err == nil {
+					text = reflected
+				}
+			}
+		}
+	}
+	sess.PushRoomLine(roomID, senderAimID, text)
+
+	responseData := map[string]interface{}{
+		"state": "delivered",
+	}
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = responseData
+	SendResponse(w, r, response, h.Logger)
+}
+
 // resolveDisplayName returns the recipient's screen name as they formatted it,
 // or "" when it cannot be determined because they are offline or blocked.
 func (h *MessagingHandler) resolveDisplayName(ctx context.Context, instance *state.SessionInstance, recipient state.IdentScreenName) string {

+ 17 - 2
server/webapi/handlers/session.go

@@ -283,7 +283,13 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 
 	// Wire buddy list refresher so feedbag SNACs from the OSCAR bridge trigger a buddylist event.
 	session.BuddyListRefresher = func(ctx context.Context) (interface{}, error) {
-		return h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		if err != nil {
+			return nil, err
+		}
+		// CANNED: keep the fake group chat present across feedbag-driven refreshes.
+		// TODO(groupchat): list the user's joined rooms.
+		return append(groups, CannedGroupChatGroup()), nil
 	}
 
 	// Wire the alias loader so OSCAR-driven im/presence events can repeat the
@@ -353,8 +359,14 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		}
 		for _, event := range events {
 			if event == "conversation" {
+				// CANNED: seed a fake group chat so we can confirm the conversation
+				// "list" event drives the client's "Group Chats" section. Real
+				// per-user room membership persistence would replace this list.
+				// TODO(groupchat): load the user's joined rooms and list them here.
 				session.EventQueue.Push(types.EventTypeConversation,
-					types.ConversationEventData("list", nil))
+					types.ConversationEventData("list", []map[string]interface{}{
+						types.ConversationGroupEntry("4-0-Canned Group Chat", "Canned Group Chat", 1),
+					}))
 				break
 			}
 		}
@@ -425,6 +437,9 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 			if buddyGroups == nil {
 				buddyGroups = []WebAPIBuddyGroup{}
 			}
+			// CANNED: surface a fake group chat under the client's "Group chats"
+			// buddy-list section. TODO(groupchat): list the user's joined rooms.
+			buddyGroups = append(buddyGroups, CannedGroupChatGroup())
 			blPayload := map[string]interface{}{"groups": buddyGroups}
 			if resp.Response.Data.Events == nil {
 				resp.Response.Data.Events = make(map[string]interface{})

+ 36 - 0
server/webapi/server.go

@@ -59,11 +59,22 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 	messagingHandler := &handlers.MessagingHandler{
 		SessionManager: sessionManager,
 		ICBMService:    handler.ICBMService,
+		ChatService:    handler.ChatService,
 		LocateService:  handler.LocateService,
 		FeedbagService: handler.FeedbagService,
 		Logger:         logger,
 	}
 
+	imservHandler := &handlers.ImservHandler{
+		SessionManager:  sessionManager,
+		ChatNavService:  handler.ChatNavService,
+		OServiceService: handler.OServiceService,
+		AuthService:     handler.AuthService,
+		ICBMService:     handler.ICBMService,
+		Participants:    handler.ChatSessionManager,
+		Logger:          logger,
+	}
+
 	preferenceHandler := &handlers.PreferenceHandler{
 		SessionManager: sessionManager,
 		FeedbagService: handler.FeedbagService,
@@ -209,6 +220,31 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			authMiddleware.CORSMiddleware(
 				authMiddleware.RequireSession(sessionManager, messagingHandler.SetTyping))))
 
+		// Group chat (imserv). The client POSTs these like im/sendIM, so accept
+		// both methods. MVP: create/join/leave; the rest of the imserv surface
+		// (invite, getMembers, settings) is not yet implemented.
+		imservRoute := func(h func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(
+					authMiddleware.RequireSession(sessionManager, h)))
+		}
+		mux.Handle("GET /imserv/create", imservRoute(imservHandler.Create))
+		mux.Handle("POST /imserv/create", imservRoute(imservHandler.Create))
+		mux.Handle("GET /imserv/join", imservRoute(imservHandler.Join))
+		mux.Handle("POST /imserv/join", imservRoute(imservHandler.Join))
+		mux.Handle("GET /imserv/delete", imservRoute(imservHandler.Leave))
+		mux.Handle("POST /imserv/delete", imservRoute(imservHandler.Leave))
+		mux.Handle("GET /imserv/invite", imservRoute(imservHandler.Invite))
+		mux.Handle("POST /imserv/invite", imservRoute(imservHandler.Invite))
+		mux.Handle("GET /imserv/reject", imservRoute(imservHandler.Reject))
+		mux.Handle("POST /imserv/reject", imservRoute(imservHandler.Reject))
+		mux.Handle("GET /imserv/getMembers", imservRoute(imservHandler.GetMembers))
+		mux.Handle("POST /imserv/getMembers", imservRoute(imservHandler.GetMembers))
+		mux.Handle("GET /imserv/getSettings", imservRoute(imservHandler.GetSettings))
+		mux.Handle("POST /imserv/getSettings", imservRoute(imservHandler.GetSettings))
+		mux.Handle("GET /imserv/getRecentActivity", imservRoute(imservHandler.GetRecentActivity))
+		mux.Handle("POST /imserv/getRecentActivity", imservRoute(imservHandler.GetRecentActivity))
+
 		// SetState only requires aimsid, no k parameter needed
 		mux.Handle("GET /presence/setState", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(

+ 17 - 0
server/webapi/types.go

@@ -24,6 +24,20 @@ type OServiceService interface {
 	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)
 }
 
+// ChatService relays messages to chat room participants. Backed by
+// foodgroup.ChatService, the same instance BOS and TOC use, so web users share
+// rooms with native clients.
+type ChatService interface {
+	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)
+}
+
+// ChatNavService creates chat rooms and serves chat room metadata. Backed by
+// foodgroup.ChatNavService.
+type ChatNavService interface {
+	CreateRoom(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)
+	RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)
+}
+
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
 	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error)
@@ -103,4 +117,7 @@ type OSCARConfig interface {
 
 type ChatSessionManager interface {
 	RemoveUserFromAllChats(user state.IdentScreenName)
+	// AllSessions returns the participants of the chat room identified by
+	// chatCookie, used to serve imserv/getMembers.
+	AllSessions(chatCookie string) []*state.Session
 }

+ 17 - 0
server/webapi/types/conversation.go

@@ -13,6 +13,23 @@ func ConversationEventData(operation string, conversations []map[string]interfac
 	}
 }
 
+// ConversationGroupEntry builds one group-chat conversation object for the Web
+// AIM client's conversation "list". The presence of the imserv field is what
+// makes the client render the entry under "Group Chats" (its aa() predicate);
+// displayID gives the room its name without a getSettings round-trip.
+func ConversationGroupEntry(imserv, displayID string, memberCount int) map[string]interface{} {
+	entry := map[string]interface{}{
+		"imserv":       imserv,
+		"aimId":        imserv,
+		"active":       0,
+		"memberCounts": memberCount,
+	}
+	if displayID != "" {
+		entry["displayId"] = displayID
+	}
+	return entry
+}
+
 // ConversationEntry builds one conversation object for the Web AIM client.
 //
 // An empty displayID is omitted rather than sent blank: the client falls back to

+ 81 - 0
server/webapi/types/events.go

@@ -23,6 +23,7 @@ const (
 	EventTypeStatus       EventType = "status"
 	EventTypeTyping       EventType = "typing"
 	EventTypePermitDeny   EventType = "permitDeny"
+	EventTypeImserv       EventType = "imserv"
 )
 
 // Event represents an event to be delivered to a web client.
@@ -58,6 +59,86 @@ type IMEvent struct {
 	AutoResp  bool     `json:"autoresponse,omitempty"`
 }
 
+// RoomIMEvent represents a group-chat (imserv) room line delivered as an `im`
+// event. The web client keys the conversation by Imserv (the room id) via
+// Source.AimID, but reads the individual speaker and text from
+// SpecialData.ImFromImserv — see the client's qv/Fg/kl parsers. SpecialIM must be
+// "imservMsg" for the client to treat the line as a room message.
+type RoomIMEvent struct {
+	Source      UserInfo        `json:"source"`
+	Imserv      string          `json:"imserv"`
+	SpecialIM   string          `json:"specialIM"`
+	SpecialData RoomSpecialData `json:"specialData"`
+	Message     string          `json:"message"`
+	MsgID       string          `json:"msgId,omitempty"`
+	Timestamp   float64         `json:"timestamp"` // float64 for AMF3 encoding
+}
+
+// RoomSpecialData carries the imserv-specific payload of a RoomIMEvent.
+type RoomSpecialData struct {
+	ImFromImserv RoomImFrom `json:"imFromImserv"`
+}
+
+// RoomInviteEvent represents a group-chat invitation, delivered (like a room
+// line) as an `im` event. The presence of SpecialData.Invitation is what flags
+// the event as an invite to the client (predicate `no()`); Imserv is the room id
+// the client passes verbatim to imserv/join to accept.
+type RoomInviteEvent struct {
+	Source      UserInfo          `json:"source"`
+	Imserv      string            `json:"imserv"`
+	Message     string            `json:"message,omitempty"`
+	MsgID       string            `json:"msgId,omitempty"`
+	Timestamp   float64           `json:"timestamp"` // float64 for AMF3 encoding
+	SpecialData RoomInviteSpecial `json:"specialData"`
+}
+
+// RoomInviteSpecial wraps the invitation object.
+type RoomInviteSpecial struct {
+	Invitation RoomInvitation `json:"invitation"`
+}
+
+// RoomInvitation identifies who invited the user and to which room. From is the
+// inviter's screen name (the client renders "<From> has invited you…");
+// GroupName is the room's friendly name.
+type RoomInvitation struct {
+	From      string `json:"from"`
+	GroupName string `json:"groupName"`
+}
+
+// RoomImFrom identifies the speaker of a room line. The client prefers OrigSender
+// and falls back to Sender; both are set to the speaker's aimId. Text is the line
+// text (the client renders this in preference to the top-level Message).
+type RoomImFrom struct {
+	OrigSender string `json:"origSender"`
+	Sender     string `json:"sender"`
+	Text       string `json:"text"`
+}
+
+// ImservEvent carries live group-chat activity (member join/leave) as its own
+// `imserv` event type. The client (parser `rB`) reads RecentActivities and, per
+// room, applies each activity to the roster (`fC`). See RoomActivity.
+type ImservEvent struct {
+	RecentActivities []ImservRoomActivity `json:"recentActivities"`
+}
+
+// ImservRoomActivity groups a room's activity records under its room id.
+type ImservRoomActivity struct {
+	Imserv     string           `json:"imserv"`
+	Activities []ImservActivity `json:"activities"`
+}
+
+// ImservActivity is a single room activity record. Action is the discriminator
+// ("memberJoin" / "memberLeft"); the client takes the affected member from
+// Member1 for both. Member2 mirrors Member1 for a self join/leave. The friendly
+// name is optional and falls back to the screen name client-side.
+type ImservActivity struct {
+	Action              string  `json:"action"`
+	Member1             string  `json:"member1"`
+	Member2             string  `json:"member2"`
+	Member1FriendlyName string  `json:"member1FriendlyName,omitempty"`
+	Timestamp           float64 `json:"timestamp"` // float64 for AMF3 encoding
+}
+
 // SentIMEvent represents a sent instant message event.
 type SentIMEvent struct {
 	Sender    UserInfo `json:"sender"` // Sender user info

+ 284 - 0
state/webapi_session.go

@@ -9,9 +9,12 @@ import (
 	"log/slog"
 	mrand "math/rand/v2"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
+	"github.com/google/uuid"
+
 	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -89,6 +92,16 @@ type WebAPISession struct {
 	logger       *slog.Logger // Logger for debugging
 	listeners    sync.WaitGroup
 
+	// chatRooms maps a joined room id (the ChatRoom cookie the web client uses as
+	// its im/sendIM target) to that room's dedicated chat SessionInstance. Each
+	// join registers a separate OSCAR chat session here, analogous to TOC's
+	// ChatRegistry. chatRoomsClosed is set once Close has drained the map, so a
+	// join racing teardown cannot re-add (and thereby leak) an instance Close will
+	// never see. Both guarded by chatRoomsMu.
+	chatRooms       map[string]*SessionInstance
+	chatRoomsClosed bool
+	chatRoomsMu     sync.Mutex
+
 	ctx    context.Context
 	cancel context.CancelFunc
 
@@ -219,10 +232,211 @@ func (s *WebAPISession) Close() {
 	s.EventQueue.Close()
 	s.OSCARSession.CloseInstance()
 
+	// Close every joined chat instance so each room announces the user's
+	// departure and SignoutChat runs. Their listeners also observe s.ctx below,
+	// but closing the instances is what tears down the room membership.
+	// chatRoomsClosed is set under the same lock so a concurrent AddChatRoom that
+	// arrives after this drain is turned away instead of re-adding an instance
+	// this drain would never close.
+	s.chatRoomsMu.Lock()
+	for _, inst := range s.chatRooms {
+		inst.CloseInstance()
+	}
+	s.chatRooms = nil
+	s.chatRoomsClosed = true
+	s.chatRoomsMu.Unlock()
+
 	s.cancel()
 	s.listeners.Wait()
 }
 
+// AddChatRoom registers a joined room's chat SessionInstance under its room id
+// (the ChatRoom cookie). Returns false if the session is already closing, in
+// which case the caller must close inst itself.
+func (s *WebAPISession) AddChatRoom(roomID string, inst *SessionInstance) bool {
+	// The closed-check and the insert must be atomic with Close's drain, so both
+	// run under chatRoomsMu. A nil map alone can't distinguish "closing" from
+	// "never used yet", which is why Close sets chatRoomsClosed.
+	s.chatRoomsMu.Lock()
+	defer s.chatRoomsMu.Unlock()
+	if s.chatRoomsClosed {
+		return false
+	}
+	if s.chatRooms == nil {
+		s.chatRooms = make(map[string]*SessionInstance)
+	}
+	s.chatRooms[roomID] = inst
+	return true
+}
+
+// ChatRoom returns the chat SessionInstance for a joined room id, or false if
+// the session is not in that room. The web client addresses a room exactly like
+// a buddy, so this is how the im/sendIM path tells a room target from a buddy.
+func (s *WebAPISession) ChatRoom(roomID string) (*SessionInstance, bool) {
+	s.chatRoomsMu.Lock()
+	defer s.chatRoomsMu.Unlock()
+	inst, ok := s.chatRooms[roomID]
+	return inst, ok
+}
+
+// RemoveChatRoom unregisters a joined room and returns its chat SessionInstance
+// so the caller can close it. Returns false if the room was not joined.
+func (s *WebAPISession) RemoveChatRoom(roomID string) (*SessionInstance, bool) {
+	s.chatRoomsMu.Lock()
+	defer s.chatRoomsMu.Unlock()
+	inst, ok := s.chatRooms[roomID]
+	if ok {
+		delete(s.chatRooms, roomID)
+	}
+	return inst, ok
+}
+
+// StartListeningToChatSession converts SNACs relayed to a joined room's chat
+// SessionInstance into web events on this session's queue. roomID is the room's
+// ChatRoom cookie, which the web client uses to key the conversation. The
+// goroutine exits when the chat instance closes (room left) or the web session is
+// torn down. closeMu is held across Add so a concurrent Close either observes the
+// listener or is observed by it — mirrors StartListeningToOSCARSession.
+func (s *WebAPISession) StartListeningToChatSession(roomID string, inst *SessionInstance) {
+	s.closeMu.Lock()
+	defer s.closeMu.Unlock()
+	if s.closed {
+		return
+	}
+
+	s.listeners.Add(1)
+	go func() {
+		defer s.listeners.Done()
+		msgCh := inst.ReceiveMessage()
+		for {
+			select {
+			case <-s.ctx.Done():
+				return
+			case <-inst.Closed():
+				return
+			case msg, ok := <-msgCh:
+				if !ok {
+					return
+				}
+				s.handleChatSNAC(roomID, msg)
+			}
+		}
+	}()
+}
+
+// handleChatSNAC converts a SNAC relayed to a room's chat instance into a web
+// event: chat messages become `im` events, and participant join/leave SNACs
+// become `imserv` activity events that live-update the client's roster.
+func (s *WebAPISession) handleChatSNAC(roomID string, msg wire.SNACMessage) {
+	if s.EventQueue == nil {
+		return
+	}
+	switch body := msg.Body.(type) {
+	case wire.SNAC_0x0E_0x06_ChatChannelMsgToClient:
+		s.handleRoomMessage(roomID, body)
+	case wire.SNAC_0x0E_0x03_ChatUsersJoined:
+		s.handleMembershipChange(roomID, "memberJoin", body.Users)
+	case wire.SNAC_0x0E_0x04_ChatUsersLeft:
+		s.handleMembershipChange(roomID, "memberLeft", body.Users)
+	}
+}
+
+// handleMembershipChange pushes an `imserv` activity event so an existing
+// participant's roster live-updates when someone joins or leaves. Each affected
+// user becomes one activity keyed to the room; the client reads member1 as the
+// affected member for both join and leave. Not gated on a subscription: the
+// event only fires for a room the user has joined, and the client's imserv
+// listener is active whenever its chat controller is. See types.ImservEvent.
+func (s *WebAPISession) handleMembershipChange(roomID, action string, users []wire.TLVUserInfo) {
+	if len(users) == 0 {
+		return
+	}
+	now := float64(time.Now().Unix())
+	activities := make([]types.ImservActivity, 0, len(users))
+	for _, user := range users {
+		// The client keys the member by normalized aimId (matching getMembers) and
+		// renders the display form from the friendly name.
+		aimID := NewIdentScreenName(user.ScreenName).String()
+		activities = append(activities, types.ImservActivity{
+			Action:              action,
+			Member1:             aimID,
+			Member2:             aimID,
+			Member1FriendlyName: user.ScreenName,
+			Timestamp:           now,
+		})
+	}
+	s.EventQueue.Push(types.EventTypeImserv, types.ImservEvent{
+		RecentActivities: []types.ImservRoomActivity{{
+			Imserv:     roomID,
+			Activities: activities,
+		}},
+	})
+}
+
+// handleRoomMessage pushes a relayed room line as an `im` event. The client keys
+// the conversation by the room id but reads the speaker and text from
+// specialData.imFromImserv, so both are populated here. See RoomIMEvent.
+func (s *WebAPISession) handleRoomMessage(roomID string, body wire.SNAC_0x0E_0x06_ChatChannelMsgToClient) {
+	if !s.IsSubscribedTo("im") {
+		return
+	}
+
+	senderInfo, ok := body.Bytes(wire.ChatTLVSenderInformation)
+	if !ok {
+		return
+	}
+	var userInfo wire.TLVUserInfo
+	if err := wire.UnmarshalBE(&userInfo, bytes.NewReader(senderInfo)); err != nil {
+		s.logger.Error("failed to unmarshal chat sender info", "err", err)
+		return
+	}
+
+	msgInfo, ok := body.Bytes(wire.ChatTLVMessageInfo)
+	if !ok {
+		return
+	}
+	text, err := wire.UnmarshalChatMessageText(msgInfo)
+	if err != nil {
+		s.logger.Error("failed to unmarshal chat message text", "err", err)
+		return
+	}
+
+	// The sender screen name is carried as its display form; the client keys the
+	// speaker by the normalized aimId and renders the display form separately.
+	speakerAimID := NewIdentScreenName(userInfo.ScreenName).String()
+	s.PushRoomLine(roomID, speakerAimID, text)
+}
+
+// PushRoomLine queues a group-chat room line as an `im` event. roomID keys the
+// conversation; speakerAimID attributes the line to the participant who spoke.
+// Both the relay listener (incoming lines) and the send path (the sender's own
+// reflected line) funnel through here so every room line has the same shape the
+// client expects — see types.RoomIMEvent.
+func (s *WebAPISession) PushRoomLine(roomID, speakerAimID, text string) {
+	if s.EventQueue == nil || !s.IsSubscribedTo("im") {
+		return
+	}
+	s.EventQueue.Push(types.EventTypeIM, types.RoomIMEvent{
+		Source: types.UserInfo{
+			AimID:    roomID,
+			UserType: "aim",
+			State:    "online",
+		},
+		Imserv:    roomID,
+		SpecialIM: "imservMsg",
+		SpecialData: types.RoomSpecialData{
+			ImFromImserv: types.RoomImFrom{
+				OrigSender: speakerAimID,
+				Sender:     speakerAimID,
+				Text:       text,
+			},
+		},
+		Message:   text,
+		MsgID:     strconv.FormatUint(mrand.Uint64(), 16),
+		Timestamp: float64(time.Now().Unix()),
+	})
+}
+
 // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
 func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
 	if s.EventQueue == nil {
@@ -269,12 +483,82 @@ func (s *WebAPISession) handleOServiceMessage(msg wire.SNACMessage) {
 func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
 	case wire.ICBMChannelMsgToClient:
+		// A chat-room invitation arrives as a channel-2 rendezvous, not a plain
+		// IM. Route it to the invite handler; everything else is a 1:1 message.
+		if body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient); ok &&
+			body.ChannelID == wire.ICBMChannelRendezvous {
+			s.handleChatInvite(body)
+			return
+		}
 		s.handleIncomingIM(msg)
 	case wire.ICBMClientEvent:
 		s.handleTypingNotification(msg)
 	}
 }
 
+// handleChatInvite converts an incoming chat-room invitation (a channel-2
+// CapChat rendezvous) into an `im` event carrying specialData.invitation, which
+// is how the web client recognizes and can accept an invite (via imserv/join
+// with the room id). Non-chat rendezvous (file transfer, etc.) are ignored.
+func (s *WebAPISession) handleChatInvite(body wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) {
+	if !s.IsSubscribedTo("im") {
+		return
+	}
+
+	rdinfo, ok := body.Bytes(wire.ICBMTLVData)
+	if !ok {
+		return
+	}
+	var frag wire.ICBMCh2Fragment
+	if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
+		return
+	}
+	if frag.Type != wire.ICBMRdvMessagePropose || uuid.UUID(frag.Capability) != wire.CapChat {
+		return
+	}
+
+	svcData, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData)
+	if !ok {
+		return
+	}
+	var roomInfo wire.ICBMRoomInfo
+	if err := wire.UnmarshalBE(&roomInfo, bytes.NewReader(svcData)); err != nil {
+		return
+	}
+
+	prompt, _ := frag.String(wire.ICBMRdvTLVTagsInvitation)
+	inviterAimID := NewIdentScreenName(body.ScreenName).String()
+
+	s.EventQueue.Push(types.EventTypeIM, types.RoomInviteEvent{
+		Source: types.UserInfo{
+			AimID:    inviterAimID,
+			UserType: "aim",
+			State:    "online",
+		},
+		Imserv:    roomInfo.Cookie,
+		Message:   prompt,
+		MsgID:     strconv.FormatUint(mrand.Uint64(), 16),
+		Timestamp: float64(time.Now().Unix()),
+		SpecialData: types.RoomInviteSpecial{
+			Invitation: types.RoomInvitation{
+				From:      body.ScreenName,
+				GroupName: roomNameFromCookie(roomInfo.Cookie),
+			},
+		},
+	})
+}
+
+// roomNameFromCookie returns the human room name embedded in a chat room cookie
+// of the form "<exchange>-<instance>-<name>". The name may itself contain "-",
+// so only the first two segments are split off. Falls back to the whole cookie.
+func roomNameFromCookie(cookie string) string {
+	parts := strings.SplitN(cookie, "-", 3)
+	if len(parts) < 3 {
+		return cookie
+	}
+	return parts[2]
+}
+
 // handleIncomingIM handles incoming instant messages.
 func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("im") {

+ 194 - 4
state/webapi_session_test.go

@@ -306,7 +306,7 @@ func TestWebAPISessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
 	s2, err := mgr.CreateSession(DisplayScreenName("bob"), "dev", []string{"presence"}, inst2, "", slog.Default())
 	assert.NoError(t, err)
 
-	mgr.Shutdown(context.Background())
+	_ = mgr.Shutdown(context.Background())
 
 	// Maps drained: the collect loop ran over both sessions.
 	assert.Empty(t, mgr.sessions)
@@ -387,7 +387,7 @@ func TestWebAPISessionManager_ShutdownWithoutReaper(t *testing.T) {
 	done := make(chan struct{})
 	go func() {
 		defer close(done)
-		mgr.Shutdown(context.Background())
+		_ = mgr.Shutdown(context.Background())
 	}()
 
 	select {
@@ -414,7 +414,7 @@ func TestWebAPISessionManager_ShutdownJoinsReaper(t *testing.T) {
 	done := make(chan struct{})
 	go func() {
 		defer close(done)
-		mgr.Shutdown(context.Background())
+		_ = mgr.Shutdown(context.Background())
 	}()
 
 	select {
@@ -435,7 +435,7 @@ func TestWebAPISessionManager_ShutdownJoinsReaper(t *testing.T) {
 // with Shutdown never starts, so it cannot reap an already-drained manager.
 func TestWebAPISessionManager_RunAfterShutdown(t *testing.T) {
 	mgr := NewWebAPISessionManager()
-	mgr.Shutdown(context.Background())
+	_ = mgr.Shutdown(context.Background())
 
 	done := make(chan struct{})
 	go func() {
@@ -892,3 +892,193 @@ func TestWebAPISession_CloseCancelsSessionContext(t *testing.T) {
 
 	assert.ErrorIs(t, sess.ctx.Err(), context.Canceled)
 }
+
+func TestWebAPISession_ChatRoomRegistry(t *testing.T) {
+	sess := &WebAPISession{
+		AimSID:     "sid",
+		ScreenName: DisplayScreenName("Ann Dupree"),
+		Events:     []string{"im"},
+		EventQueue: types.NewEventQueue(100),
+		logger:     slog.Default(),
+	}
+
+	inst := NewSession().AddInstance()
+
+	// Not joined yet.
+	_, ok := sess.ChatRoom("room1")
+	assert.False(t, ok)
+
+	require.True(t, sess.AddChatRoom("room1", inst))
+	got, ok := sess.ChatRoom("room1")
+	require.True(t, ok)
+	assert.Equal(t, inst, got)
+
+	removed, ok := sess.RemoveChatRoom("room1")
+	require.True(t, ok)
+	assert.Equal(t, inst, removed)
+
+	_, ok = sess.ChatRoom("room1")
+	assert.False(t, ok)
+}
+
+// PushRoomLine keys the conversation by the room id (Source.AimID/Imserv) while
+// attributing the line to the speaker via specialData.imFromImserv — the shape
+// the AIM web client needs to render "who said what" in a room.
+func TestWebAPISession_PushRoomLine(t *testing.T) {
+	sess := &WebAPISession{
+		AimSID:     "sid",
+		ScreenName: DisplayScreenName("Ann Dupree"),
+		Events:     []string{"im"},
+		EventQueue: types.NewEventQueue(100),
+		logger:     slog.Default(),
+	}
+
+	sess.PushRoomLine("room1", "bob", "hello room")
+
+	events := sess.EventQueue.GetAllEvents()
+	require.Len(t, events, 1)
+	require.Equal(t, types.EventTypeIM, events[0].Type)
+
+	room, ok := events[0].Data.(types.RoomIMEvent)
+	require.True(t, ok)
+	assert.Equal(t, "room1", room.Imserv)
+	assert.Equal(t, "room1", room.Source.AimID)
+	assert.Equal(t, "imservMsg", room.SpecialIM)
+	assert.Equal(t, "bob", room.SpecialData.ImFromImserv.OrigSender)
+	assert.Equal(t, "bob", room.SpecialData.ImFromImserv.Sender)
+	assert.Equal(t, "hello room", room.SpecialData.ImFromImserv.Text)
+}
+
+// A session not subscribed to "im" gets no room events.
+func TestWebAPISession_PushRoomLine_Unsubscribed(t *testing.T) {
+	sess := &WebAPISession{
+		AimSID:     "sid",
+		ScreenName: DisplayScreenName("Ann Dupree"),
+		Events:     []string{"presence"},
+		EventQueue: types.NewEventQueue(100),
+		logger:     slog.Default(),
+	}
+
+	sess.PushRoomLine("room1", "bob", "hello room")
+	assert.Empty(t, sess.EventQueue.GetAllEvents())
+}
+
+// After Close has drained the room map, a racing AddChatRoom must be turned away
+// so the caller closes the instance itself rather than leaking it into a map
+// Close will never revisit.
+func TestWebAPISession_AddChatRoom_RefusedAfterClose(t *testing.T) {
+	mgr := NewWebAPISessionManager()
+	sess, err := mgr.CreateSession(DisplayScreenName("Ann Dupree"), "dev",
+		[]string{"im"}, NewSession().AddInstance(), "", slog.Default())
+	require.NoError(t, err)
+
+	sess.Close()
+
+	inst := NewSession().AddInstance()
+	assert.False(t, sess.AddChatRoom("room1", inst))
+
+	_, ok := sess.ChatRoom("room1")
+	assert.False(t, ok)
+}
+
+// An incoming chat-room invitation (a channel-2 CapChat rendezvous) surfaces as
+// an `im` event carrying specialData.invitation, which is how the web client
+// recognizes and can accept an invite.
+func TestWebAPISession_HandleChatInvite(t *testing.T) {
+	sess := &WebAPISession{
+		AimSID:     "sid",
+		ScreenName: DisplayScreenName("Bob Smith"),
+		Events:     []string{"im"},
+		EventQueue: types.NewEventQueue(100),
+		logger:     slog.Default(),
+	}
+
+	roomInfo := wire.ICBMRoomInfo{Exchange: 4, Cookie: "4-0-Test Room", Instance: 0}
+	frag := wire.ICBMCh2Fragment{
+		Type:       wire.ICBMRdvMessagePropose,
+		Capability: wire.CapChat,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, "come join"),
+				wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, roomInfo),
+			},
+		},
+	}
+	body := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+		ChannelID:   wire.ICBMChannelRendezvous,
+		TLVUserInfo: wire.TLVUserInfo{ScreenName: "anndupree"},
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{wire.NewTLVBE(wire.ICBMTLVData, frag)},
+		},
+	}
+	sess.handleICBMMessage(wire.SNACMessage{
+		Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMChannelMsgToClient},
+		Body:  body,
+	})
+
+	events := sess.EventQueue.GetAllEvents()
+	require.Len(t, events, 1)
+	require.Equal(t, types.EventTypeIM, events[0].Type)
+
+	inv, ok := events[0].Data.(types.RoomInviteEvent)
+	require.True(t, ok, "expected a RoomInviteEvent")
+	assert.Equal(t, "4-0-Test Room", inv.Imserv)
+	assert.Equal(t, "anndupree", inv.Source.AimID)
+	assert.Equal(t, "anndupree", inv.SpecialData.Invitation.From)
+	assert.Equal(t, "Test Room", inv.SpecialData.Invitation.GroupName)
+}
+
+// A relayed ChatUsersJoined/Left SNAC surfaces as an `imserv` activity event so
+// an existing participant's roster live-updates.
+func TestWebAPISession_MembershipChange(t *testing.T) {
+	tests := []struct {
+		name    string
+		snac    wire.SNACMessage
+		action  string
+		member  string
+	}{
+		{
+			name: "join",
+			snac: wire.SNACMessage{
+				Frame: wire.SNACFrame{FoodGroup: wire.Chat, SubGroup: wire.ChatUsersJoined},
+				Body:  wire.SNAC_0x0E_0x03_ChatUsersJoined{Users: []wire.TLVUserInfo{{ScreenName: "Bob Smith"}}},
+			},
+			action: "memberJoin",
+			member: "bobsmith",
+		},
+		{
+			name: "leave",
+			snac: wire.SNACMessage{
+				Frame: wire.SNACFrame{FoodGroup: wire.Chat, SubGroup: wire.ChatUsersLeft},
+				Body:  wire.SNAC_0x0E_0x04_ChatUsersLeft{Users: []wire.TLVUserInfo{{ScreenName: "Bob Smith"}}},
+			},
+			action: "memberLeft",
+			member: "bobsmith",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := &WebAPISession{
+				AimSID: "sid", ScreenName: DisplayScreenName("Ann Dupree"),
+				Events: []string{"im", "imserv"}, EventQueue: types.NewEventQueue(100), logger: slog.Default(),
+			}
+			sess.handleChatSNAC("4-0-Test Room", tt.snac)
+
+			events := sess.EventQueue.GetAllEvents()
+			require.Len(t, events, 1)
+			require.Equal(t, types.EventTypeImserv, events[0].Type)
+
+			ev, ok := events[0].Data.(types.ImservEvent)
+			require.True(t, ok)
+			require.Len(t, ev.RecentActivities, 1)
+			assert.Equal(t, "4-0-Test Room", ev.RecentActivities[0].Imserv)
+			require.Len(t, ev.RecentActivities[0].Activities, 1)
+			act := ev.RecentActivities[0].Activities[0]
+			assert.Equal(t, tt.action, act.Action)
+			assert.Equal(t, tt.member, act.Member1)
+			assert.Equal(t, tt.member, act.Member2)
+			assert.Equal(t, "Bob Smith", act.Member1FriendlyName)
+		})
+	}
+}