Explorar o código

webapi: fix up IM and permit/deny

- typing events work
- privacy options (permit deny) now correctly load and sacve
- bring in ICBM service to replace bespoke IM logic
- fix bug where client can't receive more than 1 message
Mike hai 1 mes
pai
achega
bc99ef2348

+ 0 - 65
server/webapi/adapters.go

@@ -110,71 +110,6 @@ func WebAPIToICBM(sender state.IdentScreenName, recipient string, message string
 	return icbmMsg, nil
 }
 
-// ICBMToWebAPIEvent converts an incoming ICBM message to a WebAPI event
-func ICBMToWebAPIEvent(icbm wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) (types.Event, error) {
-	// Extract message text
-	var messageText string
-	var autoResponse bool
-
-	// Check for AOL IM data
-	if msgData, hasMsg := icbm.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
-		msgText, err := wire.UnmarshalICBMMessageText(msgData)
-		if err == nil {
-			messageText = msgText
-		}
-	}
-
-	// Check for auto-response flag
-	if _, hasAutoResp := icbm.Bytes(wire.ICBMTLVAutoResponse); hasAutoResp {
-		autoResponse = true
-	}
-
-	// Extract sender screen name from TLVUserInfo
-	senderScreenName := ""
-	if icbm.ScreenName != "" {
-		senderScreenName = icbm.ScreenName
-	}
-
-	// Create WebAPI event
-	event := types.Event{
-		Type:      types.EventTypeIM,
-		Timestamp: time.Now().Unix(),
-		Data: types.IMEvent{
-			Source: types.UserInfo{
-				AimID:     senderScreenName,
-				DisplayID: senderScreenName,
-				UserType:  "aim",
-				State:     "online",
-			},
-			Message:   messageText,
-			Timestamp: float64(time.Now().Unix()),
-			AutoResp:  autoResponse,
-		},
-	}
-
-	return event, nil
-}
-
-// TypingNotificationToWebAPIEvent converts an OSCAR typing notification to a WebAPI event
-func TypingNotificationToWebAPIEvent(notification wire.SNAC_0x04_0x14_ICBMClientEvent) types.Event {
-	typing := false
-	switch notification.Event {
-	case 0x0002: // Typing started
-		typing = true
-	case 0x0001: // Typing stopped
-		typing = false
-	}
-
-	return types.Event{
-		Type:      types.EventTypeTyping,
-		Timestamp: time.Now().Unix(),
-		Data: types.TypingEvent{
-			From:   notification.ScreenName,
-			Typing: typing,
-		},
-	}
-}
-
 // PresenceUpdateToWebAPIEvent converts OSCAR buddy arrival/departure to WebAPI event
 func PresenceUpdateToWebAPIEvent(screenName string, online bool, awayMsg string, statusBitmask uint32) types.Event {
 	stateStr := "offline"

+ 109 - 240
server/webapi/handlers/messaging.go

@@ -1,15 +1,12 @@
 package handlers
 
 import (
-	"bytes"
 	"context"
 	"crypto/rand"
 	"encoding/binary"
-	"errors"
 	"fmt"
 	"log/slog"
 	"net/http"
-	"strconv"
 	"time"
 
 	"github.com/mk6i/open-oscar-server/server/webapi/types"
@@ -17,29 +14,22 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// MessageRelayer defines methods for relaying messages between users
-type MessageRelayer interface {
-	RelayToScreenName(ctx context.Context, recipient state.IdentScreenName, msg wire.SNACMessage)
-}
-
-// OfflineMessageManager defines methods for managing offline messages
-type OfflineMessageManager interface {
-	SaveMessage(ctx context.Context, msg state.OfflineMessage) (int, error)
-}
-
 // RelationshipFetcher defines methods for fetching user relationships
 type RelationshipFetcher interface {
 	Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error)
 }
 
+// ICBMService defines methods for ICBM operations
+type ICBMService interface {
+	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
+}
+
 // MessagingHandler handles Web AIM API messaging endpoints
 type MessagingHandler struct {
-	SessionManager        *state.WebAPISessionManager
-	MessageRelayer        MessageRelayer
-	OfflineMessageManager OfflineMessageManager
-	SessionRetriever      SessionRetriever
-	RelationshipFetcher   RelationshipFetcher
-	Logger                *slog.Logger
+	SessionManager *state.WebAPISessionManager
+	ICBMService    ICBMService
+	Logger         *slog.Logger
 }
 
 // queryOrFormParam returns a request parameter from the query string or, for POST
@@ -98,35 +88,12 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 
 	// Parse optional parameters
 	autoResponse := queryOrFormParam(r, "autoResponse") == "1"
-	offlineIMParam := queryOrFormParam(r, "offlineIM")
-	offlineIM := offlineIMParam != "0" && offlineIMParam != "false" // default to true
+	//offlineIMParam := queryOrFormParam(r, "offlineIM") what is this for
+	//offlineIM := offlineIMParam != "0" && offlineIMParam != "false" // default to true
 
 	// Create recipient identifier
 	recipientIdent := state.NewIdentScreenName(recipient)
 
-	// Check blocking relationship
-	rel, err := h.RelationshipFetcher.Relationship(ctx, sess.ScreenName.IdentScreenName(), recipientIdent)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to fetch relationship", "error", err)
-		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
-		return
-	}
-
-	// Check if sender blocks recipient or recipient blocks sender
-	if rel.BlocksYou {
-		// Recipient blocks sender - pretend recipient is offline
-		h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
-		return
-	}
-	if rel.YouBlock {
-		// Sender has blocked recipient - cannot send message
-		h.sendErrorResponse(w, http.StatusForbidden, "cannot send message to blocked user")
-		return
-	}
-
-	// Check if recipient is online
-	recipientSession := h.SessionRetriever.RetrieveSession(recipientIdent)
-
 	// Generate message cookie
 	var cookie [8]byte
 	if _, err := rand.Read(cookie[:]); err != nil {
@@ -164,117 +131,99 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 	sn := sess.ScreenName.String()
 	sess.AddStoredIM(recipient, sn, message, messageID, nowSec)
 
-	if recipientSession == nil {
-		// Recipient is offline
-		if offlineIM {
-			// Save offline message
-			offlineMsg := state.OfflineMessage{
-				Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-					Cookie:     cookieUint64,
-					ChannelID:  wire.ICBMChannelIM,
-					ScreenName: recipient,
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICBMTLVAOLIMData, h.encodeIMMessage(message, autoResponse)),
-							wire.NewTLVBE(wire.ICBMTLVStore, uint8(1)), // store offline
-						},
-					},
-				},
-				Recipient: recipientIdent,
-				Sender:    sess.ScreenName.IdentScreenName(),
-				Sent:      time.Now().UTC(),
-			}
+	// Recipient is online, deliver message
+	clientIM := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		Cookie:       cookieUint64,
+		ChannelID:    wire.ICBMChannelIM,
+		ScreenName:   recipient,
+		TLVRestBlock: wire.TLVRestBlock{},
+	}
 
-			count, err := h.OfflineMessageManager.SaveMessage(ctx, offlineMsg)
-			if err != nil {
-				if errors.Is(err, state.ErrOfflineInboxFull) {
-					h.Logger.WarnContext(ctx, "offline inbox full",
-						"from", sess.ScreenName.String(),
-						"to", recipient)
-					h.sendErrorResponse(w, http.StatusConflict, "recipient inbox full")
-					return
-				}
-				h.Logger.ErrorContext(ctx, "failed to save offline message",
-					"from", sess.ScreenName.String(),
-					"to", recipient,
-					"error", err)
-				h.sendErrorResponse(w, http.StatusInternalServerError, "failed to save offline message")
-				return
-			}
+	// Add message data
+	frags, err := wire.ICBMFragmentList(message)
+	if err != nil {
+		h.sendErrorResponse(w, http.StatusInternalServerError, "failed to send message")
+		return
+	}
 
-			h.Logger.DebugContext(ctx, "saved offline message",
-				"from", sess.ScreenName.String(),
-				"to", recipient,
-				"count", count)
+	clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
 
-			h.pushSenderWebAPIEvents(sess, sn, recipient, message, messageID, now, autoResponse)
-		} else {
-			// Recipient is offline and offline delivery is disabled
-			h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
-			return
-		}
-	} else {
-		// Recipient is online, deliver message
-		clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-			Cookie:       cookieUint64,
-			ChannelID:    wire.ICBMChannelIM,
-			TLVUserInfo:  senderInfo,
-			TLVRestBlock: wire.TLVRestBlock{},
-		}
+	// Add auto-response flag if applicable
+	if autoResponse {
+		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
+	}
 
-		// Add message data
-		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, h.encodeIMMessage(message, autoResponse)))
+	frame := wire.SNACFrame{
+		FoodGroup: wire.ICBM,
+		SubGroup:  wire.ICBMChannelMsgToHost,
+		RequestID: wire.ReqIDFromServer,
+	}
+	resp, err := h.ICBMService.ChannelMsgToHost(r.Context(), sess.OSCARSession, frame, clientIM)
+
+	if err != nil {
+		h.sendErrorResponse(w, http.StatusInternalServerError, "failed to send message")
+		return
+	}
 
-		// Add auto-response flag if applicable
-		if autoResponse {
-			clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
+	if resp != nil {
+		switch {
+		case resp.Frame.FoodGroup == wire.ICBM && resp.Frame.SubGroup == wire.ICBMErr:
+			if errSn, ok := resp.Body.(wire.SNACError); ok {
+				switch errSn.Code {
+				case wire.ErrorCodeNotLoggedOn:
+					subCode, hasSubCode := errSn.Uint16BE(wire.ErrorTLVErrorSubcode)
+					if hasSubCode {
+						if subCode == wire.ICBMSubErrOfflineIMExceedMax {
+							h.Logger.DebugContext(r.Context(), "user's offline messages full")
+						}
+					} else {
+						h.Logger.DebugContext(r.Context(), "recipient offline")
+					}
+					return
+				case wire.ErrorCodeInLocalPermitDeny:
+					h.Logger.DebugContext(r.Context(), "you blocked this user")
+					return
+				}
+			}
+		case resp.Frame.FoodGroup == wire.ICBM && resp.Frame.SubGroup == wire.ICBMHostAck:
+			h.Logger.DebugContext(r.Context(), "received host ack")
 		}
+	}
 
-		// Send message to recipient
-		h.MessageRelayer.RelayToScreenName(ctx, recipientIdent, wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMChannelMsgToClient,
-				RequestID: wire.ReqIDFromServer,
+	// Queue IM event for the recipient's WebAPI session if they have one
+	if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
+		recipientWebSession.AddStoredIM(sn, sn, message, messageID, nowSec)
+		eventData := types.IMEvent{
+			Source: types.UserInfo{
+				AimID:     sn,
+				DisplayID: sn,
+				UserType:  "aim",
+				State:     "online",
 			},
-			Body: clientIM,
-		})
-
-		// Queue IM event for the recipient's WebAPI session if they have one
-		if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
-			recipientWebSession.AddStoredIM(sn, sn, message, messageID, nowSec)
-			eventData := types.IMEvent{
-				Source: types.UserInfo{
-					AimID:     sn,
-					DisplayID: sn,
-					UserType:  "aim",
-					State:     "online",
-				},
-				Message:   message,
-				MsgID:     messageID,
-				Timestamp: now,
-				AutoResp:  autoResponse,
-			}
-			recipientWebSession.EventQueue.Push(types.EventTypeIM, eventData)
-			if recipientWebSession.IsSubscribedTo("conversation") {
-				recipientWebSession.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
-					types.ConversationEntry(sn, sn, message, messageID, sn, false, 1),
-				}))
-			}
+			Message:   message,
+			MsgID:     messageID,
+			Timestamp: now,
+			AutoResp:  autoResponse,
+		}
+		recipientWebSession.EventQueue.Push(types.EventTypeIM, eventData)
+		if recipientWebSession.IsSubscribedTo("conversation") {
+			recipientWebSession.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
+				types.ConversationEntry(sn, sn, message, messageID, sn, false, 1),
+			}))
 		}
+	}
 
-		h.pushSenderWebAPIEvents(sess, sn, recipient, message, messageID, now, autoResponse)
+	h.pushSenderWebAPIEvents(sess, sn, recipient, message, messageID, now, autoResponse)
 
-		h.Logger.DebugContext(ctx, "queued sentIM event for sender",
-			"from", sess.ScreenName.String(),
-			"to", recipient,
-			"eventType", types.EventTypeSentIM,
-		)
+	h.Logger.DebugContext(ctx, "queued sentIM event for sender",
+		"from", sess.ScreenName.String(),
+		"to", recipient,
+		"eventType", types.EventTypeSentIM,
+	)
 
-		h.Logger.DebugContext(ctx, "delivered instant message",
-			"from", sess.ScreenName.String(),
-			"to", recipient)
-	}
+	h.Logger.DebugContext(ctx, "delivered instant message",
+		"from", sess.ScreenName.String(),
+		"to", recipient)
 
 	// Send success response
 	responseData := map[string]interface{}{
@@ -313,26 +262,6 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *state.WebAPISession, sen
 	}
 }
 
-// encodeIMMessage encodes a text message into the OSCAR IM format
-func (h *MessagingHandler) encodeIMMessage(text string, autoResponse bool) []byte {
-	// Create ICBM fragment list for the message
-	frags, err := wire.ICBMFragmentList(text)
-	if err != nil {
-		// If fragment creation fails, return simple text bytes
-		return []byte(text)
-	}
-
-	// Marshal the fragments
-	buf := &bytes.Buffer{}
-	for _, frag := range frags {
-		if err := wire.MarshalBE(frag, buf); err != nil {
-			// If marshaling fails, return simple text bytes
-			return []byte(text)
-		}
-	}
-	return buf.Bytes()
-}
-
 // sendErrorResponse sends an error response in Web AIM API format
 func (h *MessagingHandler) sendErrorResponse(w http.ResponseWriter, statusCode int, errorText string) {
 	SendError(w, statusCode, errorText)
@@ -371,92 +300,32 @@ func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	typingStr := r.URL.Query().Get("typing")
-	typing := false
-	if typingStr != "" {
-		var err error
-		typing, err = strconv.ParseBool(typingStr)
-		if err != nil {
-			// Try numeric format (0/1)
-			typing = typingStr == "1"
-		}
+	typingStatus := r.URL.Query().Get("typingStatus")
+	if typingStatus == "" {
+		typingStatus = "none"
 	}
 
-	// Create recipient identifier
-	recipientIdent := state.NewIdentScreenName(recipient)
-
-	// Check blocking relationship
-	rel, err := h.RelationshipFetcher.Relationship(ctx, sess.ScreenName.IdentScreenName(), recipientIdent)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to fetch relationship", "error", err)
-		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
-		return
-	}
-
-	// Check if sender blocks recipient or recipient blocks sender
-	if rel.BlocksYou || rel.YouBlock {
-		// Either party blocks the other - silently succeed without sending notification
-		h.sendSuccessResponse(w, r, nil)
-		return
+	var event uint16
+	switch typingStatus {
+	case "typing":
+		event = 0x0002
+	case "typed":
+		event = 0x0001
+	default:
+		event = 0x0000
 	}
 
-	// Check if recipient is online
-	recipientSession := h.SessionRetriever.RetrieveSession(recipientIdent)
-	if recipientSession == nil {
-		// Silently succeed even if recipient is offline
-		h.sendSuccessResponse(w, r, nil)
-		return
+	inBody := wire.SNAC_0x04_0x14_ICBMClientEvent{
+		ChannelID:  wire.ICBMChannelIM,
+		ScreenName: recipient,
+		Event:      event,
 	}
-
-	// Generate typing notification cookie
-	var cookie [8]byte
-	if _, err := rand.Read(cookie[:]); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to generate typing cookie", "error", err)
+	if err := h.ICBMService.ClientEvent(ctx, sess.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to send typing notification", "error", err)
 		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
 		return
 	}
-	cookieUint64 := binary.BigEndian.Uint64(cookie[:])
-
-	// Create typing notification
-	var notificationType uint16
-	if typing {
-		notificationType = 0x0002 // Typing started
-	} else {
-		notificationType = 0x0001 // Typing stopped
-	}
-
-	typingNotification := wire.SNAC_0x04_0x14_ICBMClientEvent{
-		Cookie:     cookieUint64,
-		ChannelID:  wire.ICBMChannelIM,
-		ScreenName: sess.ScreenName.String(),
-		Event:      notificationType,
-	}
-
-	// Send typing notification to recipient
-	h.MessageRelayer.RelayToScreenName(ctx, recipientIdent, wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMClientEvent,
-			RequestID: wire.ReqIDFromServer,
-		},
-		Body: typingNotification,
-	})
 
-	// Queue typing event for the recipient's WebAPI session if they have one
-	if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
-		eventData := types.TypingEvent{
-			From:   sess.ScreenName.String(),
-			Typing: typing,
-		}
-		recipientWebSession.EventQueue.Push(types.EventTypeTyping, eventData)
-	}
-
-	h.Logger.DebugContext(ctx, "sent typing notification",
-		"from", sess.ScreenName.String(),
-		"to", recipient,
-		"typing", typing)
-
-	// Send success response
 	h.sendSuccessResponse(w, r, nil)
 }
 

+ 81 - 223
server/webapi/handlers/messaging_test.go

@@ -16,88 +16,59 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// MockMessageRelayer is a mock implementation of MessageRelayer
-type MockMessageRelayer struct {
+// MockICBMService is a mock implementation of ICBMService
+type MockICBMService struct {
 	mock.Mock
 }
 
-func (m *MockMessageRelayer) RelayToScreenName(ctx context.Context, recipient state.IdentScreenName, msg wire.SNACMessage) {
-	m.Called(ctx, recipient, msg)
-}
-
-// MockOfflineMessageManager is a mock implementation of OfflineMessageManager
-type MockOfflineMessageManager struct {
-	mock.Mock
-}
-
-func (m *MockOfflineMessageManager) SaveMessage(ctx context.Context, msg state.OfflineMessage) (int, error) {
-	args := m.Called(ctx, msg)
-	return args.Int(0), args.Error(1)
-}
-
-// MockSessionRetriever is a mock implementation of SessionRetriever
-type MockSessionRetriever struct {
-	mock.Mock
-}
-
-func (m *MockSessionRetriever) AllSessions() []*state.Session {
-	args := m.Called()
-	if sessions := args.Get(0); sessions != nil {
-		return sessions.([]*state.Session)
-	}
-	return nil
-}
-
-func (m *MockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session {
-	args := m.Called(screenName)
-	if session := args.Get(0); session != nil {
-		return session.(*state.Session)
+func (m *MockICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	if msg := args.Get(0); msg != nil {
+		return msg.(*wire.SNACMessage), args.Error(1)
 	}
-	return nil
-}
-
-// MockRelationshipFetcher is a mock implementation of RelationshipFetcher
-type MockRelationshipFetcher struct {
-	mock.Mock
+	return nil, args.Error(1)
 }
 
-func (m *MockRelationshipFetcher) Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error) {
-	args := m.Called(ctx, me, them)
-	return args.Get(0).(state.Relationship), args.Error(1)
+func (m *MockICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	return args.Error(0)
 }
 
 // createTestSessionManager creates a WebAPISessionManager with a pre-populated session.
 func createTestSessionManager(screenName string) (*state.WebAPISessionManager, string) {
+	return createTestSessionManagerWithOSCAR(screenName, nil)
+}
+
+// createTestSessionManagerWithOSCAR creates a WebAPISessionManager with an OSCAR session instance set.
+func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*state.WebAPISessionManager, string) {
 	mgr := state.NewWebAPISessionManager()
 	session, _ := mgr.CreateSession(
 		context.Background(),
 		state.DisplayScreenName(screenName),
 		"test-dev",
-		[]string{"im", "presence", "buddylist", "sentIM"},
-		nil,
+		[]string{"im", "presence", "buddylist", "sentIM", "typing"},
+		oscarSession,
 		slog.Default(),
 	)
 	return mgr, session.AimSID
 }
 
 func TestMessagingHandler_SendIM(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockMessageRelayer, *MockOfflineMessageManager, *MockSessionRetriever, *MockRelationshipFetcher)
+		setupMocks         func(*MockICBMService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
-			name:        "Success_OnlineRecipient",
+			name:        "Success",
 			queryParams: "t=recipient&message=hello+world",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("recipient")).
-					Return(&state.Session{})
-				mr.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
-					Return()
+			setupMocks: func(is *MockICBMService) {
+				is.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
+					Return(nil, nil)
 			},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
@@ -107,117 +78,38 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 			},
 		},
 		{
-			name:        "Success_OfflineRecipient_OfflineIM",
-			queryParams: "t=offlineuser&message=hello&offlineIM=1",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
-					Return(nil)
-				om.On("SaveMessage", mock.Anything, mock.AnythingOfType("state.OfflineMessage")).
-					Return(1, nil)
-			},
-			expectedStatusCode: http.StatusOK,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, `"statusCode":200`)
-				assert.Contains(t, body, `"msgId"`)
-			},
-		},
-		{
-			name:        "Error_MissingRecipient",
-			queryParams: "message=hello",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-			},
+			name:               "Error_MissingRecipient",
+			queryParams:        "message=hello",
+			setupMocks:         func(is *MockICBMService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "missing required parameter: t")
 			},
 		},
 		{
-			name:        "Error_MissingMessage",
-			queryParams: "t=recipient",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-			},
+			name:               "Error_MissingMessage",
+			queryParams:        "t=recipient",
+			setupMocks:         func(is *MockICBMService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "missing required parameter: message")
 			},
 		},
-		{
-			name:        "Error_BlockedBySender",
-			queryParams: "t=blockeduser&message=hello",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("blockeduser")).
-					Return(state.Relationship{YouBlock: true}, nil)
-			},
-			expectedStatusCode: http.StatusForbidden,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "cannot send message to blocked user")
-			},
-		},
-		{
-			name:        "Error_BlockedByRecipient",
-			queryParams: "t=blocker&message=hello",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("blocker")).
-					Return(state.Relationship{BlocksYou: true}, nil)
-			},
-			expectedStatusCode: http.StatusNotFound,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "recipient is not online")
-			},
-		},
-		{
-			name:        "Error_OfflineInboxFull",
-			queryParams: "t=offlineuser&message=hello&offlineIM=1",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
-					Return(nil)
-				om.On("SaveMessage", mock.Anything, mock.AnythingOfType("state.OfflineMessage")).
-					Return(0, state.ErrOfflineInboxFull)
-			},
-			expectedStatusCode: http.StatusConflict,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "recipient inbox full")
-			},
-		},
-		{
-			name:        "Error_OfflineRecipient_NoOfflineIM",
-			queryParams: "t=offlineuser&message=hello&offlineIM=0",
-			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
-					Return(nil)
-			},
-			expectedStatusCode: http.StatusNotFound,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "recipient is not online")
-			},
-		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			messageRelayer := &MockMessageRelayer{}
-			offlineMsgMgr := &MockOfflineMessageManager{}
-			sessionRetriever := &MockSessionRetriever{}
-			relFetcher := &MockRelationshipFetcher{}
+			icbmService := &MockICBMService{}
 
-			sessionMgr, aimsid := createTestSessionManager("testuser")
+			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 			handler := &MessagingHandler{
-				SessionManager:        sessionMgr,
-				MessageRelayer:        messageRelayer,
-				OfflineMessageManager: offlineMsgMgr,
-				SessionRetriever:      sessionRetriever,
-				RelationshipFetcher:   relFetcher,
-				Logger:                slog.Default(),
+				SessionManager: sessionMgr,
+				ICBMService:    icbmService,
+				Logger:         slog.Default(),
 			}
 
-			tt.setupMocks(messageRelayer, offlineMsgMgr, sessionRetriever, relFetcher)
+			tt.setupMocks(icbmService)
 
 			reqURL := "/im/sendIM?aimsid=" + aimsid + "&" + tt.queryParams
 			req, err := http.NewRequest("GET", reqURL, nil)
@@ -234,34 +126,24 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 				tt.checkResponse(t, responseBody)
 			}
 
-			messageRelayer.AssertExpectations(t)
-			offlineMsgMgr.AssertExpectations(t)
-			sessionRetriever.AssertExpectations(t)
-			relFetcher.AssertExpectations(t)
+			icbmService.AssertExpectations(t)
 		})
 	}
 }
 
 func TestMessagingHandler_SendIM_POST(t *testing.T) {
-	messageRelayer := &MockMessageRelayer{}
-	sessionRetriever := &MockSessionRetriever{}
-	relFetcher := &MockRelationshipFetcher{}
+	oscarInstance := state.NewSession().AddInstance()
+	icbmService := &MockICBMService{}
 
-	sessionMgr, aimsid := createTestSessionManager("testuser")
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
-	relFetcher.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
-		Return(state.Relationship{}, nil)
-	sessionRetriever.On("RetrieveSession", state.NewIdentScreenName("recipient")).
-		Return(&state.Session{})
-	messageRelayer.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
-		Return()
+	icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
+		Return(nil, nil)
 
 	handler := &MessagingHandler{
-		SessionManager:      sessionMgr,
-		MessageRelayer:      messageRelayer,
-		SessionRetriever:    sessionRetriever,
-		RelationshipFetcher: relFetcher,
-		Logger:              slog.Default(),
+		SessionManager: sessionMgr,
+		ICBMService:    icbmService,
+		Logger:         slog.Default(),
 	}
 
 	body := strings.NewReader("message=" + url.QueryEscape("hello from post"))
@@ -274,9 +156,7 @@ func TestMessagingHandler_SendIM_POST(t *testing.T) {
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	assert.Contains(t, rr.Body.String(), `"msgId"`)
-	messageRelayer.AssertExpectations(t)
-	sessionRetriever.AssertExpectations(t)
-	relFetcher.AssertExpectations(t)
+	icbmService.AssertExpectations(t)
 }
 
 func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
@@ -312,23 +192,24 @@ func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
 }
 
 func TestMessagingHandler_SetTyping(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockMessageRelayer, *MockSessionRetriever, *MockRelationshipFetcher)
+		setupMocks         func(*MockICBMService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
 			name:        "Success_TypingStarted",
-			queryParams: "t=recipient&typing=true",
-			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("recipient")).
-					Return(&state.Session{})
-				mr.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
-					Return()
+			queryParams: "t=recipient&typingStatus=typing",
+			setupMocks: func(is *MockICBMService) {
+				is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient",
+					Event:      0x0002,
+				}).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
@@ -336,50 +217,33 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 			},
 		},
 		{
-			name:        "Success_TypingStopped",
-			queryParams: "t=recipient&typing=false",
-			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("recipient")).
-					Return(&state.Session{})
-				mr.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
-					Return()
-			},
-			expectedStatusCode: http.StatusOK,
-		},
-		{
-			name:        "Success_BlockedSilent",
-			queryParams: "t=blockeduser&typing=true",
-			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("blockeduser")).
-					Return(state.Relationship{YouBlock: true}, nil)
-				// No relay should happen
+			name:        "Success_TypingPaused",
+			queryParams: "t=recipient&typingStatus=typed",
+			setupMocks: func(is *MockICBMService) {
+				is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient",
+					Event:      0x0001,
+				}).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, `"statusCode":200`)
-			},
 		},
 		{
-			name:        "Success_OfflineRecipient",
-			queryParams: "t=offlineuser&typing=true",
-			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
-				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
-					Return(state.Relationship{}, nil)
-				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
-					Return(nil)
-				// No relay should happen for offline users
+			name:        "Success_TypingStopped",
+			queryParams: "t=recipient&typingStatus=none",
+			setupMocks: func(is *MockICBMService) {
+				is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "recipient",
+					Event:      0x0000,
+				}).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, `"statusCode":200`)
-			},
 		},
 		{
 			name:               "Error_MissingRecipient",
-			queryParams:        "typing=true",
-			setupMocks:         func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {},
+			queryParams:        "typingStatus=typing",
+			setupMocks:         func(is *MockICBMService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "missing required parameter: t")
@@ -389,21 +253,17 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			messageRelayer := &MockMessageRelayer{}
-			sessionRetriever := &MockSessionRetriever{}
-			relFetcher := &MockRelationshipFetcher{}
+			icbmService := &MockICBMService{}
 
-			sessionMgr, aimsid := createTestSessionManager("testuser")
+			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 			handler := &MessagingHandler{
-				SessionManager:      sessionMgr,
-				MessageRelayer:      messageRelayer,
-				SessionRetriever:    sessionRetriever,
-				RelationshipFetcher: relFetcher,
-				Logger:              slog.Default(),
+				SessionManager: sessionMgr,
+				ICBMService:    icbmService,
+				Logger:         slog.Default(),
 			}
 
-			tt.setupMocks(messageRelayer, sessionRetriever, relFetcher)
+			tt.setupMocks(icbmService)
 
 			reqURL := "/im/setTyping?aimsid=" + aimsid + "&" + tt.queryParams
 			req, err := http.NewRequest("GET", reqURL, nil)
@@ -420,9 +280,7 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 				tt.checkResponse(t, responseBody)
 			}
 
-			messageRelayer.AssertExpectations(t)
-			sessionRetriever.AssertExpectations(t)
-			relFetcher.AssertExpectations(t)
+			icbmService.AssertExpectations(t)
 		})
 	}
 }

+ 40 - 0
server/webapi/handlers/mocks_test.go

@@ -0,0 +1,40 @@
+package handlers
+
+import (
+	"context"
+
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+)
+
+// MockSessionRetriever is a mock implementation of SessionRetriever
+type MockSessionRetriever struct {
+	mock.Mock
+}
+
+func (m *MockSessionRetriever) AllSessions() []*state.Session {
+	args := m.Called()
+	if sessions := args.Get(0); sessions != nil {
+		return sessions.([]*state.Session)
+	}
+	return nil
+}
+
+func (m *MockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session {
+	args := m.Called(screenName)
+	if session := args.Get(0); session != nil {
+		return session.(*state.Session)
+	}
+	return nil
+}
+
+// MockRelationshipFetcher is a mock implementation of RelationshipFetcher
+type MockRelationshipFetcher struct {
+	mock.Mock
+}
+
+func (m *MockRelationshipFetcher) Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error) {
+	args := m.Called(ctx, me, them)
+	return args.Get(0).(state.Relationship), args.Error(1)
+}

+ 110 - 101
server/webapi/handlers/preference.go

@@ -3,6 +3,7 @@ package handlers
 import (
 	"context"
 	"log/slog"
+	"math/rand"
 	"net/http"
 	"strconv"
 	"strings"
@@ -13,9 +14,9 @@ import (
 
 // PreferenceHandler handles Web AIM API preference-related endpoints.
 type PreferenceHandler struct {
+	FeedbagService    FeedbagService
 	SessionManager    *state.WebAPISessionManager
 	PreferenceManager PreferenceManager
-	PermitDenyManager PermitDenyManager
 	Logger            *slog.Logger
 }
 
@@ -25,23 +26,11 @@ type PreferenceManager interface {
 	GetPreferences(ctx context.Context, screenName state.IdentScreenName) (map[string]interface{}, error)
 }
 
-// PermitDenyManager provides methods to manage permit/deny lists.
-type PermitDenyManager interface {
-	SetPDMode(ctx context.Context, screenName state.IdentScreenName, mode wire.FeedbagPDMode) error
-	GetPDMode(ctx context.Context, screenName state.IdentScreenName) (wire.FeedbagPDMode, error)
-	GetPermitList(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
-	GetDenyList(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
-	AddPermitBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
-	RemovePermitBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
-	AddDenyBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
-	RemoveDenyBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
-}
-
 // PermitDenyData contains permit/deny list information.
 type PermitDenyData struct {
-	PDMode     int      `json:"pdMode" xml:"pdMode"`
-	PermitList []string `json:"permitList,omitempty" xml:"permitList>user,omitempty"`
-	DenyList   []string `json:"denyList,omitempty" xml:"denyList>user,omitempty"`
+	PDMode     string   `json:"pdMode" xml:"pdMode"`
+	PermitList []string `json:"allows,omitempty" xml:"allows>user,omitempty"`
+	DenyList   []string `json:"blocks,omitempty" xml:"blocks>user,omitempty"`
 }
 
 // SetPreferences handles GET /preference/set requests to update user preferences.
@@ -277,118 +266,145 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
 	}
 
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
+	if err != nil {
+		h.sendError(w, http.StatusInternalServerError, "failed to retrieve feedbag")
+		return
+	}
+
+	reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		h.sendError(w, http.StatusInternalServerError, "failed to retrieve feedbag")
+		return
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+
 	// Get pdMode parameter
 	pdModeStr := r.URL.Query().Get("pdMode")
 	if pdModeStr != "" {
-		pdMode, err := strconv.Atoi(pdModeStr)
-		if err != nil || pdMode < 0 || pdMode > 5 {
-			h.sendError(w, http.StatusBadRequest, "invalid pdMode value (must be 0-5)")
-			return
-		}
-
-		// Set the PD mode
-		if err := h.PermitDenyManager.SetPDMode(ctx, session.ScreenName.IdentScreenName(), wire.FeedbagPDMode(pdMode)); err != nil {
-			h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
-			h.sendError(w, http.StatusInternalServerError, "failed to update PD mode")
+		switch pdModeStr { // todo: are the string ints possible inputs?
+		case "permitAll", "1":
+			fl.SetMode(uint8(wire.FeedbagPDModePermitAll))
+		case "denyAll", "2":
+			fl.SetMode(uint8(wire.FeedbagPDModeDenyAll))
+		case "permitSome", "3":
+			fl.SetMode(uint8(wire.FeedbagPDModePermitSome))
+		case "denySome", "4":
+			fl.SetMode(uint8(wire.FeedbagPDModeDenySome))
+		case "permitOnList", "5":
+			fl.SetMode(uint8(wire.FeedbagPDModePermitOnList))
+		default:
+			h.sendError(w, http.StatusBadRequest, "invalid pdMode value")
 			return
 		}
 	}
 
 	// Handle permit list updates
-	if permitAdd := r.URL.Query().Get("permitAdd"); permitAdd != "" {
-		users := strings.Split(permitAdd, ",")
+	if pdAllow := r.URL.Query().Get("pdAllow"); pdAllow != "" {
+		users := strings.Split(pdAllow, ",")
 		for _, user := range users {
 			user = strings.TrimSpace(user)
 			if user != "" {
-				targetSN := state.NewIdentScreenName(user)
-				if err := h.PermitDenyManager.AddPermitBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
-					h.Logger.ErrorContext(ctx, "failed to add to permit list", "user", user, "err", err.Error())
-				}
+				fl.PermitUser(user)
 			}
 		}
 	}
 
-	if permitRemove := r.URL.Query().Get("permitRemove"); permitRemove != "" {
-		users := strings.Split(permitRemove, ",")
+	if pdAllowRemove := r.URL.Query().Get("pdAllowRemove"); pdAllowRemove != "" {
+		users := strings.Split(pdAllowRemove, ",")
 		for _, user := range users {
 			user = strings.TrimSpace(user)
 			if user != "" {
-				targetSN := state.NewIdentScreenName(user)
-				if err := h.PermitDenyManager.RemovePermitBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
-					h.Logger.ErrorContext(ctx, "failed to remove from permit list", "user", user, "err", err.Error())
-				}
+				fl.DeletePermit(user)
 			}
 		}
 	}
 
 	// Handle deny list updates
-	if denyAdd := r.URL.Query().Get("denyAdd"); denyAdd != "" {
-		users := strings.Split(denyAdd, ",")
+	if pdBlock := r.URL.Query().Get("pdBlock"); pdBlock != "" {
+		users := strings.Split(pdBlock, ",")
 		for _, user := range users {
 			user = strings.TrimSpace(user)
 			if user != "" {
-				targetSN := state.NewIdentScreenName(user)
-				if err := h.PermitDenyManager.AddDenyBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
-					h.Logger.ErrorContext(ctx, "failed to add to deny list", "user", user, "err", err.Error())
-				}
+				fl.DenyUser(user)
 			}
 		}
 	}
 
-	if denyRemove := r.URL.Query().Get("denyRemove"); denyRemove != "" {
-		users := strings.Split(denyRemove, ",")
+	if pdBlockRemove := r.URL.Query().Get("pdBlockRemove"); pdBlockRemove != "" {
+		users := strings.Split(pdBlockRemove, ",")
 		for _, user := range users {
 			user = strings.TrimSpace(user)
 			if user != "" {
-				targetSN := state.NewIdentScreenName(user)
-				if err := h.PermitDenyManager.RemoveDenyBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
-					h.Logger.ErrorContext(ctx, "failed to remove from deny list", "user", user, "err", err.Error())
-				}
+				fl.DeleteDeny(user)
 			}
 		}
 	}
 
-	// Get updated PD data
-	pdMode, _ := h.PermitDenyManager.GetPDMode(ctx, session.ScreenName.IdentScreenName())
-	permitList, _ := h.PermitDenyManager.GetPermitList(ctx, session.ScreenName.IdentScreenName())
-	denyList, _ := h.PermitDenyManager.GetDenyList(ctx, session.ScreenName.IdentScreenName())
-
-	// Convert to string arrays
-	permitUsers := make([]string, len(permitList))
-	for i, u := range permitList {
-		permitUsers[i] = u.String()
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := h.FeedbagService.UpsertItem(ctx, session.OSCARSession, frame, pending); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
+			h.sendError(w, http.StatusInternalServerError, "failed to update PD mode")
+			return
+		}
 	}
-	denyUsers := make([]string, len(denyList))
-	for i, u := range denyList {
-		denyUsers[i] = u.String()
+
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
+		body := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
+		if _, err := h.FeedbagService.DeleteItem(ctx, session.OSCARSession, frame, body); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
+			h.sendError(w, http.StatusInternalServerError, "failed to update PD mode")
+			return
+		}
 	}
 
+	pdd := permitDenyData(fl.Items())
+
 	h.Logger.DebugContext(ctx, "permit/deny settings updated",
 		"screenName", session.ScreenName.String(),
-		"pdMode", pdMode,
-		"permitCount", len(permitUsers),
-		"denyCount", len(denyUsers),
+		"pdMode", pdd.PDMode,
+		"permitCount", len(pdd.PermitList),
+		"denyCount", len(pdd.DenyList),
 	)
 
-	// Note: We don't broadcast immediate presence changes here.
-	// The blocking relationship is now in the database and will be respected
-	// by all future presence checks and message routing.
-	// The blocked users will appear offline to each other on the next presence update.
-
-	// Send response
-	permitDenyData := PermitDenyData{
-		PDMode:     int(pdMode),
-		PermitList: permitUsers,
-		DenyList:   denyUsers,
-	}
-
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
-	response.Response.Data = permitDenyData
+	response.Response.Data = pdd
 	SendResponse(w, r, response, h.Logger)
 }
 
+func permitDenyData(fl []wire.FeedbagItem) PermitDenyData {
+	pdd := PermitDenyData{}
+	for _, item := range fl {
+		switch item.ClassID {
+		case wire.FeedbagClassIDDeny:
+			pdd.DenyList = append(pdd.DenyList, item.Name)
+		case wire.FeedbagClassIDPermit:
+			pdd.PermitList = append(pdd.PermitList, item.Name)
+		case wire.FeedbagClassIdPdinfo:
+			mode, _ := item.Uint8(wire.FeedbagAttributesPdMode)
+			switch wire.FeedbagPDMode(mode) {
+			case wire.FeedbagPDModePermitAll:
+				pdd.PDMode = "permitAll"
+			case wire.FeedbagPDModeDenyAll:
+				pdd.PDMode = "denyAll"
+			case wire.FeedbagPDModePermitSome:
+				pdd.PDMode = "permitSome"
+			case wire.FeedbagPDModeDenySome:
+				pdd.PDMode = "denySome"
+			case wire.FeedbagPDModePermitOnList:
+				pdd.PDMode = "permitOnList"
+			}
+		}
+	}
+	return pdd
+}
+
 // GetPermitDeny handles GET /preference/getPermitDeny requests to retrieve permit/deny settings.
 func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
@@ -412,39 +428,31 @@ func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request
 		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
 	}
 
-	// Get PD data
-	pdMode, _ := h.PermitDenyManager.GetPDMode(ctx, session.ScreenName.IdentScreenName())
-	permitList, _ := h.PermitDenyManager.GetPermitList(ctx, session.ScreenName.IdentScreenName())
-	denyList, _ := h.PermitDenyManager.GetDenyList(ctx, session.ScreenName.IdentScreenName())
-
-	// Convert to string arrays
-	permitUsers := make([]string, len(permitList))
-	for i, u := range permitList {
-		permitUsers[i] = u.String()
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
+	if err != nil {
+		h.sendError(w, http.StatusInternalServerError, "failed to retrieve feedbag")
+		return
 	}
-	denyUsers := make([]string, len(denyList))
-	for i, u := range denyList {
-		denyUsers[i] = u.String()
+
+	reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		h.sendError(w, http.StatusInternalServerError, "failed to retrieve feedbag")
+		return
 	}
 
+	pdd := permitDenyData(reply.Items)
 	h.Logger.DebugContext(ctx, "permit/deny settings retrieved",
 		"screenName", session.ScreenName.String(),
-		"pdMode", pdMode,
-		"permitCount", len(permitUsers),
-		"denyCount", len(denyUsers),
+		"pdMode", pdd.PDMode,
+		"permitCount", len(pdd.PermitList),
+		"denyCount", len(pdd.DenyList),
 	)
 
-	// Send response
-	permitDenyData := PermitDenyData{
-		PDMode:     int(pdMode),
-		PermitList: permitUsers,
-		DenyList:   denyUsers,
-	}
-
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
-	response.Response.Data = permitDenyData
+	response.Response.Data = pdd
 	SendResponse(w, r, response, h.Logger)
 }
 
@@ -479,5 +487,6 @@ func (h *PreferenceHandler) getDefaultPreferences() map[string]interface{} {
 }
 
 func (h *PreferenceHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
+	// todo log
 	SendError(w, statusCode, message)
 }

+ 28 - 0
server/webapi/handlers/session.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"log/slog"
 	"net/http"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -27,6 +28,7 @@ type SessionHandler struct {
 	BuddyListRegistry   BuddyListRegistry
 	BuddyBroadcaster    BuddyBroadcaster
 	FeedbagRetriever    FeedbagRetriever
+	FeedbagService      FeedbagService
 	OSCARBuddyService   OSCARBuddyService
 	BuddyListManager    *BuddyListManager
 	Logger              *slog.Logger
@@ -301,6 +303,18 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				}
 			})
 
+			if err := h.FeedbagService.Use(ctx, oscarInstance); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to use feedbag", "err", err.Error())
+			}
+
+			// A web client signals that it wants typing events through its event
+			// subscription, not through a stored feedbag buddy pref. Reflect that
+			// on the OSCAR session so ICBMService attaches the WantEvents TLV to
+			// outgoing IMs, prompting recipients to send typing notifications
+			// back. This must run after FeedbagService.Use, which otherwise
+			// overwrites the flag from stored prefs the web user may not have set.
+			oscarInstance.Session().SetTypingEventsEnabled(slices.Contains(events, "typing"))
+
 			oscarInstance.SetSignonComplete()
 
 			// Emulate an OSCAR client buddy watch list.
@@ -354,6 +368,20 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		return h.BuddyListManager.GetBuddyListForUser(ctx, session)
 	}
 
+	// Wire permit/deny refresher so FeedbagUpdateItem SNACs trigger a permitDeny event.
+	session.PermitDenyRefresher = func(ctx context.Context) (interface{}, error) {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+		fb, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
+		if err != nil {
+			return nil, err
+		}
+		reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+		if !ok {
+			return nil, fmt.Errorf("unexpected feedbag reply type")
+		}
+		return permitDenyData(reply.Items), nil
+	}
+
 	// Store client info
 	session.ClientName = clientName
 	session.ClientVersion = clientVersion

+ 1 - 9
server/webapi/handlers/webapi_event_converter.go

@@ -108,15 +108,7 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 		result["eventData"] = event.Data
 
 	case types.EventTypeTyping:
-		if typingEvent, ok := event.Data.(types.TypingEvent); ok {
-			// Gromit expects 'aimId' and 'typingStatus'
-			result["eventData"] = map[string]interface{}{
-				"aimId":        typingEvent.From,
-				"typingStatus": typingEvent.Typing,
-			}
-		} else {
-			result["eventData"] = event.Data
-		}
+		result["eventData"] = event.Data
 
 	case types.EventTypeSentIM:
 		if sentIMEvent, ok := event.Data.(types.SentIMEvent); ok {

+ 6 - 8
server/webapi/server.go

@@ -36,6 +36,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		BuddyListRegistry:   handler.BuddyListRegistry,
 		BuddyBroadcaster:    handler.BuddyBroadcaster,
 		FeedbagRetriever:    handler.FeedbagRetriever,
+		FeedbagService:      handler.FeedbagService,
 		OSCARBuddyService:   handler.BuddyService,
 		BuddyListManager:    handler.BuddyListManager.(*handlers.BuddyListManager),
 		Logger:              logger,
@@ -69,19 +70,16 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 	// Phase 2: Messaging handler
 	messagingHandler := &handlers.MessagingHandler{
-		SessionManager:        sessionManager,
-		MessageRelayer:        handler.MessageRelayer,
-		OfflineMessageManager: handler.OfflineMessageManager,
-		SessionRetriever:      handler.SessionRetriever,
-		RelationshipFetcher:   handler.RelationshipFetcher,
-		Logger:                logger,
+		SessionManager: sessionManager,
+		ICBMService:    handler.ICBMService,
+		Logger:         logger,
 	}
 
 	// Phase 3: Preference handler
 	preferenceHandler := &handlers.PreferenceHandler{
 		SessionManager:    sessionManager,
 		PreferenceManager: handler.PreferenceManager,
-		PermitDenyManager: handler.PermitDenyManager,
+		FeedbagService:    handler.FeedbagService,
 		Logger:            logger,
 	}
 
@@ -205,7 +203,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /im/sendIM", sendIMHandler)
 		mux.Handle("POST /im/sendIM", sendIMHandler)
 
-		mux.Handle("GET /im/setTyping", authMiddleware.Authenticate(
+		mux.Handle("GET /im/setTyping", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
 				http.HandlerFunc(messagingHandler.SetTyping))))
 

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

@@ -2,9 +2,6 @@ package types
 
 import "time"
 
-// EventTypeConversation is the fetchEvents type for chat list sync.
-const EventTypeConversation EventType = "conversation"
-
 // ConversationEventData builds a conversation fetchEvents payload.
 func ConversationEventData(operation string, conversations []map[string]interface{}) map[string]interface{} {
 	if conversations == nil {

+ 10 - 9
server/webapi/types/events.go

@@ -12,17 +12,18 @@ import (
 type EventType string
 
 const (
-	// Event types that can be subscribed to
 	EventTypeBuddyList    EventType = "buddylist"
-	EventTypePresence     EventType = "presence"
+	EventTypeConversation EventType = "conversation"
 	EventTypeIM           EventType = "im"
-	EventTypeSentIM       EventType = "sentIM"
-	EventTypeTyping       EventType = "typing"
-	EventTypeStatus       EventType = "status"
 	EventTypeOfflineIM    EventType = "offlineIM"
-	EventTypeSessionEnded EventType = "sessionEnded"
-	EventTypeRateLimit    EventType = "rateLimit"
 	EventTypePreference   EventType = "preference"
+	EventTypePresence     EventType = "presence"
+	EventTypeRateLimit    EventType = "rateLimit"
+	EventTypeSentIM       EventType = "sentIM"
+	EventTypeSessionEnded EventType = "sessionEnded"
+	EventTypeStatus       EventType = "status"
+	EventTypeTyping       EventType = "typing"
+	EventTypePermitDeny   EventType = "permitDeny"
 )
 
 // Event represents an event to be delivered to a web client.
@@ -74,8 +75,8 @@ type UserInfo struct {
 
 // TypingEvent represents a typing notification event.
 type TypingEvent struct {
-	From   string `json:"from"`
-	Typing bool   `json:"typing"`
+	AimID        string `json:"aimId"`
+	TypingStatus string `json:"typingStatus"`
 }
 
 // BuddyListEvent represents a buddy list change event.

+ 8 - 0
state/feedbag_list.go

@@ -263,6 +263,14 @@ func (f *FeedbagList) PendingDeletes() []wire.FeedbagItem {
 	return result
 }
 
+func (f *FeedbagList) Items() []wire.FeedbagItem {
+	var result []wire.FeedbagItem
+	for _, p := range f.items {
+		result = append(result, *p)
+	}
+	return result
+}
+
 // rootGroup retrieves the root group, creating one if non-existent.
 func (f *FeedbagList) rootGroup() *wire.FeedbagItem {
 	var root *wire.FeedbagItem

+ 62 - 31
state/webapi_session.go

@@ -6,6 +6,7 @@ import (
 	"encoding/hex"
 	"errors"
 	"log/slog"
+	mrand "math/rand/v2"
 	"strconv"
 	"sync"
 	"time"
@@ -23,25 +24,26 @@ var (
 
 // WebAPISession represents an active Web AIM API session.
 type WebAPISession struct {
-	AimSID             string                                         // Unique session ID for web client
-	ScreenName         DisplayScreenName                              // User identity
-	OSCARSession       *SessionInstance                               // Bridge to existing OSCAR session
-	Events             []string                                       // Subscribed event types
-	EventQueue         *types.EventQueue                              // Per-session event queue
-	DevID              string                                         // Developer ID that created this session
-	ClientName         string                                         // Client application name
-	ClientVersion      string                                         // Client application version
-	CreatedAt          time.Time                                      // SessionInstance creation time
-	LastAccessed       time.Time                                      // Last activity time
-	ExpiresAt          time.Time                                      // SessionInstance expiration time
-	FetchTimeout       int                                            // Long-polling timeout in milliseconds
-	TimeToNextFetch    int                                            // Suggested delay before next fetch
-	RemoteAddr         string                                         // Client IP address
-	TempBuddies        map[string]bool                                // Temporary buddies for this session only
-	BuddyListRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
-	imLog              map[string][]WebAPIStoredIM
-	imLogMu            sync.Mutex
-	logger             *slog.Logger // Logger for debugging
+	AimSID              string                                         // Unique session ID for web client
+	ScreenName          DisplayScreenName                              // User identity
+	OSCARSession        *SessionInstance                               // Bridge to existing OSCAR session
+	Events              []string                                       // Subscribed event types
+	EventQueue          *types.EventQueue                              // Per-session event queue
+	DevID               string                                         // Developer ID that created this session
+	ClientName          string                                         // Client application name
+	ClientVersion       string                                         // Client application version
+	CreatedAt           time.Time                                      // SessionInstance creation time
+	LastAccessed        time.Time                                      // Last activity time
+	ExpiresAt           time.Time                                      // SessionInstance expiration time
+	FetchTimeout        int                                            // Long-polling timeout in milliseconds
+	TimeToNextFetch     int                                            // Suggested delay before next fetch
+	RemoteAddr          string                                         // Client IP address
+	TempBuddies         map[string]bool                                // Temporary buddies for this session only
+	BuddyListRefresher  func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
+	PermitDenyRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push permitDeny event
+	imLog               map[string][]WebAPIStoredIM
+	imLogMu             sync.Mutex
+	logger              *slog.Logger // Logger for debugging
 }
 
 // IsExpired checks if the session has expired.
@@ -148,7 +150,11 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 	// Check if it's an auto-response (channel 2)
 	autoResponse := body.ChannelID == 0x0002
 
-	msgID := strconv.FormatUint(body.Cookie, 10)
+	// msgId must be unique per delivered event. The OSCAR cookie is not a
+	// reliable unique id (some clients reuse it across messages), and the web
+	// client dedupes its conversation list by msgId, silently dropping any
+	// collisions. Mint a fresh random id instead of reusing body.Cookie.
+	msgID := strconv.FormatUint(mrand.Uint64(), 16)
 	partner := body.ScreenName
 	nowSec := time.Now().Unix()
 	s.AddStoredIM(partner, partner, messageText, msgID, nowSec)
@@ -195,12 +201,20 @@ func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
 		return
 	}
 
-	// Event types: 0=stopped typing, 1=text typed, 2=typing
-	isTyping := body.Event == 1 || body.Event == 2
+	// Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
+	var typingStatus string
+	switch body.Event {
+	case 0x0002:
+		typingStatus = "typing"
+	case 0x0001:
+		typingStatus = "typed"
+	default:
+		typingStatus = "none"
+	}
 
 	typingEvent := types.TypingEvent{
-		From:   body.ScreenName,
-		Typing: isTyping,
+		AimID:        body.ScreenName,
+		TypingStatus: typingStatus,
 	}
 
 	s.EventQueue.Push(types.EventTypeTyping, typingEvent)
@@ -275,15 +289,32 @@ func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
 func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
 	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
-		if s.BuddyListRefresher == nil {
-			return
+		if s.BuddyListRefresher != nil {
+			groups, err := s.BuddyListRefresher(context.Background())
+			if err != nil {
+				s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
+			} else {
+				s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
+			}
 		}
-		groups, err := s.BuddyListRefresher(context.Background())
-		if err != nil {
-			s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
-			return
+		if msg.Frame.SubGroup == wire.FeedbagUpdateItem && s.PermitDenyRefresher != nil {
+			body, ok := msg.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem)
+			if ok {
+				for _, item := range body.Items {
+					if item.ClassID == wire.FeedbagClassIDPermit ||
+						item.ClassID == wire.FeedbagClassIDDeny ||
+						item.ClassID == wire.FeedbagClassIdPdinfo {
+						pdd, err := s.PermitDenyRefresher(context.Background())
+						if err != nil {
+							s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
+						} else {
+							s.EventQueue.Push(types.EventTypePermitDeny, pdd)
+						}
+						break
+					}
+				}
+			}
 		}
-		s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
 	}
 }