Browse Source

implement ODir food group / user directory search

Mike 1 year ago
parent
commit
92c37bc0e7
42 changed files with 5577 additions and 339 deletions
  1. 6 0
      .mockery.yaml
  2. 39 1
      README.md
  3. 297 33
      api.yml
  4. 22 1
      cmd/server/main.go
  5. 2 1
      config/config.go
  6. 4 1
      config/settings.env
  7. 15 15
      foodgroup/icq.go
  8. 11 11
      foodgroup/icq_test.go
  9. 30 6
      foodgroup/locate.go
  10. 160 26
      foodgroup/locate_test.go
  11. 89 89
      foodgroup/mock_icq_user_finder_test.go
  12. 325 0
      foodgroup/mock_profile_manager_test.go
  13. 200 0
      foodgroup/odir.go
  14. 516 0
      foodgroup/odir_test.go
  15. 46 0
      foodgroup/oservice.go
  16. 20 0
      foodgroup/oservice_test.go
  17. 53 0
      foodgroup/test_helpers.go
  18. 15 10
      foodgroup/types.go
  19. 206 0
      server/http/mgmt_api.go
  20. 643 0
      server/http/mgmt_api_test.go
  21. 355 0
      server/http/mock_directory_manager_test.go
  22. 56 0
      server/http/test_helpers.go
  23. 36 4
      server/http/types.go
  24. 6 6
      server/oscar/handler/icq.go
  25. 3 3
      server/oscar/handler/icq_test.go
  26. 12 6
      server/oscar/handler/locate.go
  27. 4 4
      server/oscar/handler/locate_test.go
  28. 53 53
      server/oscar/handler/mock_icq_service_test.go
  29. 51 27
      server/oscar/handler/mock_locate_test.go
  30. 152 0
      server/oscar/handler/mock_odir_service_test.go
  31. 53 0
      server/oscar/handler/odir.go
  32. 96 0
      server/oscar/handler/odir_test.go
  33. 19 4
      server/oscar/handler/routes.go
  34. 208 0
      state/migrations/0009_aim_directory.down.sql
  35. 270 0
      state/migrations/0009_aim_directory.up.sql
  36. 42 0
      state/user.go
  37. 502 18
      state/user_store.go
  38. 853 20
      state/user_store_test.go
  39. 70 0
      wire/snacs.go
  40. 7 0
      wire/snacs_string.go
  41. 10 0
      wire/tlv.go
  42. 20 0
      wire/tlv_test.go

+ 6 - 0
.mockery.yaml

@@ -37,6 +37,9 @@ packages:
       MessageRelayer:
         config:
           filename: "mock_message_relayer_test.go"
+      DirectoryManager:
+        config:
+          filename: "mock_directory_manager_test.go"
   github.com/mk6i/retro-aim-server/server/oscar/handler:
     interfaces:
       ResponseWriter:
@@ -75,6 +78,9 @@ packages:
       ICQService:
         config:
           filename: "mock_icq_service_test.go"
+      ODirService:
+        config:
+          filename: "mock_odir_service_test.go"
   github.com/mk6i/retro-aim-server/foodgroup:
     interfaces:
       FeedbagManager:

+ 39 - 1
README.md

@@ -27,12 +27,13 @@ The following features are supported:
 - [x] Blocking (v3.5+)
 - [x] Visibility Toggle / Idle Notification
 - [x] Warning
+- [x] User Directory Search
 
 **ICQ**
 - [x] Windows ICQ Clients: 2000b (more to come soon)
 - [x] Instant Messaging
 - [x] Profiles
-- [x] User search
+- [x] User Search
 - [x] Presence Statuses
 - [x] Offline Messaging
 
@@ -177,6 +178,43 @@ curl -d'{"name":"Office Hijinks"}' http://localhost:8080/chat/room/public
 curl http://localhost:8080/chat/room/public
 ```
 
+#### Create Category
+
+```shell
+curl -d'{"name": "Programming Languages"}' http://localhost:8080/directory/category
+```
+
+#### Create Keyword
+
+```shell
+curl -d'{"category_id": 1, "name": "c++"}' http://localhost:8080/directory/keyword
+```
+
+#### Get Directory Categories
+
+```shell
+curl http://localhost:8080/directory/category
+```
+
+#### Get Directory Keywords by Category
+
+```shell
+curl http://localhost:8080/directory/category/1/keyword
+```
+
+#### Delete Directory Category
+
+```shell
+curl -X DELETE http://localhost:8080/directory/category/1
+```
+
+#### Delete Directory Keyword
+
+```shell
+curl -X DELETE http://localhost:8080/directory/keyword/2
+```
+
+
 ## 🔗 Acknowledgements
 
 - [aim-oscar-server](https://github.com/ox/aim-oscar-server) is another cool open source AIM server project.

+ 297 - 33
api.yml

@@ -77,11 +77,11 @@ paths:
       summary: Get account details for a specific screen name.
       description: Retrieve account details for a specific screen name.
       parameters:
-      - name: screenname
-        in: path
-        description: User's AIM screen name or ICQ UIN.
-        required: true
-        type: string
+        - name: screenname
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          type: string
       responses:
         '200':
           description: Successful response containing account details
@@ -90,24 +90,24 @@ paths:
               schema:
                 type: object
                 properties:
-                    id:
-                      type: string
-                      description: User's unique identifier.
-                    screen_name:
-                      type: string
-                      description: User's AIM screen name or ICQ UIN.
-                    profile:
-                      type: string
-                      description: User's AIM profile HTML.
-                    email_address:
-                      type: string
-                      description: User's email address
-                    confirmed:
-                      type: bool
-                      description: User's account confirmation status
-                    is_icq:
-                      type: boolean
-                      description: If true, indicates an ICQ user instead of an AIM user.
+                  id:
+                    type: string
+                    description: User's unique identifier.
+                  screen_name:
+                    type: string
+                    description: User's AIM screen name or ICQ UIN.
+                  profile:
+                    type: string
+                    description: User's AIM profile HTML.
+                  email_address:
+                    type: string
+                    description: User's email address
+                  confirmed:
+                    type: bool
+                    description: User's account confirmation status
+                  is_icq:
+                    type: boolean
+                    description: If true, indicates an ICQ user instead of an AIM user.
         '404':
           description: User not found.
 
@@ -116,11 +116,11 @@ paths:
       summary: Get AIM buddy icon for a screen name
       description: Retrieve account buddy icon for a specific screen name.
       parameters:
-      - name: screenname
-        in: path
-        description: User's AIM screen name or ICQ UIN.
-        required: true
-        type: string
+        - name: screenname
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          type: string
       responses:
         '200':
           description: Successful response containing buddy icon bytes
@@ -188,11 +188,11 @@ paths:
       summary: Get active sessions for a given screen name or UIN.
       description: Retrieve a list of active sessions of a specific logged in user.
       parameters:
-      - name: screenname
-        in: path
-        description: User's AIM screen name or ICQ UIN.
-        required: true
-        type: string
+        - name: screenname
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          type: string
       responses:
         '200':
           description: Successful response containing a list of active sessions for the given screen name
@@ -397,3 +397,267 @@ paths:
                   date:
                     type: string
                     description: The build date and timestamp in RFC3339 format.
+
+  /directory/category:
+    get:
+      summary: Get all keyword categories
+      description: Retrieve a list of all keyword categories.
+      responses:
+        '200':
+          description: Successful response containing a list of keyword categories.
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: object
+                  required:
+                    - id
+                    - name
+                  properties:
+                    id:
+                      type: integer
+                      description: The unique identifier of the keyword category.
+                    name:
+                      type: string
+                      description: The name of the keyword category.
+    post:
+      summary: Create a new keyword category
+      description: Create a new keyword category.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required:
+                - name
+              properties:
+                name:
+                  type: string
+                  description: The name of the keyword category.
+      responses:
+        '201':
+          description: Keyword category created successfully.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id:
+                    type: integer
+                    description: The ID keyword category.
+                  name:
+                    type: string
+                    description: The name of the keyword category.
+        '400':
+          description: Malformed input body.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '409':
+          description: A category with the specified name already exists.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+
+  /directory/category/{id}:
+    delete:
+      summary: Delete a keyword category
+      description: Delete a keyword category specified by its ID.
+      parameters:
+        - name: id
+          in: path
+          description: The ID of the keyword category.
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Keyword category deleted successfully.
+        '400':
+          description: Invalid category ID.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '404':
+          description: Keyword category not found.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '409':
+          description: The keyword category is currently in use and cannot be deleted.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+
+  /directory/category/{id}/keyword:
+    get:
+      summary: Get all keywords in a category
+      description: Retrieve a list of all keywords in the specified category.
+      parameters:
+        - name: id
+          in: path
+          description: The ID of the keyword category.
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: Successful response containing a list of keywords.
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: object
+                  required:
+                    - id
+                    - name
+                    - parent
+                  properties:
+                    id:
+                      type: integer
+                      description: The unique identifier of the keyword.
+                    name:
+                      type: string
+                      description: The name of the keyword.
+                    parent:
+                      type: integer
+                      description: The ID of the parent keyword category.
+        '400':
+          description: Invalid category ID.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '404':
+          description: Keyword category not found.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+
+  /directory/keyword:
+    post:
+      summary: Create a new keyword
+      description: Create a new keyword in a category.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required:
+                - name
+                - parent
+              properties:
+                name:
+                  type: string
+                  description: The name of the keyword.
+                parent:
+                  type: integer
+                  description: The ID of the parent keyword category.
+      responses:
+        '201':
+          description: Keyword created successfully.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id:
+                    type: integer
+                    description: The keyword ID.
+                  name:
+                    type: string
+                    description: The name of the keyword.
+                  parent:
+                    type: integer
+                    description: The ID of the parent keyword category.
+        '400':
+          description: Malformed input body.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '404':
+          description: Parent keyword category not found.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '409':
+          description: A keyword with the specified name already exists.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+
+  /directory/keyword/{id}:
+    delete:
+      summary: Delete a keyword
+      description: Delete a keyword specified by its ID.
+      parameters:
+        - name: id
+          in: path
+          description: The ID of the keyword.
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Keyword deleted successfully.
+        '404':
+          description: Keyword not found.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string
+        '409':
+          description: Conflict. The keyword is currently in use and cannot be deleted.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  message:
+                    type: string

+ 22 - 1
cmd/server/main.go

@@ -87,7 +87,8 @@ func main() {
 			Commit:  commit,
 			Date:    date,
 		}
-		http.StartManagementAPI(bld, cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, sessionManager, feedbagStore, feedbagStore, feedbagStore, feedbagStore, logger)
+		http.StartManagementAPI(bld, cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager,
+			feedbagStore, sessionManager, feedbagStore, feedbagStore, feedbagStore, feedbagStore, logger)
 		wg.Done()
 	}()
 	go func(logger *slog.Logger) {
@@ -233,6 +234,26 @@ func main() {
 		}.Start()
 		wg.Done()
 	}(logger)
+	go func(logger *slog.Logger) {
+		logger = logger.With("svc", "ODIR")
+		sessionManager := state.NewInMemorySessionManager(logger)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
+		oServiceService := foodgroup.NewOServiceServiceForODir(cfg, logger)
+
+		oDirService := foodgroup.NewODirService(logger, feedbagStore)
+		oscar.BOSServer{
+			AuthService: authService,
+			Config:      cfg,
+			Handler: handler.NewODirRouter(handler.Handlers{
+				OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
+				ODirHandler:     handler.NewODirHandler(logger, oDirService),
+			}),
+			Logger:         logger,
+			OnlineNotifier: oServiceService,
+			ListenAddr:     net.JoinHostPort("", cfg.ODirPort),
+		}.Start()
+		wg.Done()
+	}(logger)
 
 	wg.Wait()
 }

+ 2 - 1
config/config.go

@@ -11,11 +11,12 @@ type Config struct {
 	ChatNavPort string `envconfig:"CHAT_NAV_PORT" required:"true" val:"5193" description:"The port that the chat nav service binds to."`
 	ChatPort    string `envconfig:"CHAT_PORT" required:"true" val:"5192" description:"The port that the chat service binds to."`
 	AdminPort   string `envconfig:"ADMIN_PORT" required:"true" val:"5196" description:"The port that the admin service binds to."`
+	ODirPort    string `envconfig:"ODIR_PORT" required:"true" val:"5197" description:"The port that the ODir service binds to."`
 	DBPath      string `envconfig:"DB_PATH" required:"true" val:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
 	DisableAuth bool   `envconfig:"DISABLE_AUTH" required:"true" val:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
 	FailFast    bool   `envconfig:"FAIL_FAST" required:"true" val:"false" description:"Crash the server in case it encounters a client message type it doesn't recognize. This makes failures obvious for debugging purposes."`
 	LogLevel    string `envconfig:"LOG_LEVEL" required:"true" val:"info" description:"Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 'error'."`
-	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5196 are open on your firewall."`
+	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5197 are open on your firewall."`
 }
 
 type Build struct {

+ 4 - 1
config/settings.env

@@ -25,6 +25,9 @@ export CHAT_PORT=5192
 # The port that the admin service binds to.
 export ADMIN_PORT=5196
 
+# The port that the ODir service binds to.
+export ODIR_PORT=5197
+
 # The path to the SQLite database file. The file and DB schema are auto-created
 # if they doesn't exist.
 export DB_PATH=oscar.sqlite
@@ -48,6 +51,6 @@ export LOG_LEVEL=info
 # server and AIM client(s) are running on the same machine. For LAN-only
 # clients, a private IP address (e.g. 192.168..) or hostname should suffice. For
 # clients connecting over the Internet, specify your public IP address and
-# ensure that TCP ports 5190-5196 are open on your firewall.
+# ensure that TCP ports 5190-5197 are open on your firewall.
 export OSCAR_HOST=127.0.0.1
 

+ 15 - 15
foodgroup/icq.go

@@ -54,7 +54,7 @@ func (s ICQService) DeleteMsgReq(ctx context.Context, sess *state.Session, seq u
 	return nil
 }
 
-func (s ICQService) FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
+func (s ICQService) FindByICQName(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     sess.UIN(),
@@ -65,10 +65,10 @@ func (s ICQService) FindByDetails(ctx context.Context, sess *state.Session, req
 		ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
 	}
 
-	res, err := s.userFinder.FindByDetails(req.FirstName, req.LastName, req.NickName)
+	res, err := s.userFinder.FindByICQName(req.FirstName, req.LastName, req.NickName)
 
 	if err != nil {
-		s.logger.Error("FindByDetails failed", "err", err.Error())
+		s.logger.Error("FindByICQName failed", "err", err.Error())
 		resp.Success = wire.ICQStatusCodeErr
 		return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
 			Message: resp,
@@ -98,7 +98,7 @@ func (s ICQService) FindByDetails(ctx context.Context, sess *state.Session, req
 	return nil
 }
 
-func (s ICQService) FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
+func (s ICQService) FindByICQEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     sess.UIN(),
@@ -110,13 +110,13 @@ func (s ICQService) FindByEmail(ctx context.Context, sess *state.Session, req wi
 	}
 	resp.LastResult()
 
-	res, err := s.userFinder.FindByEmail(req.Email)
+	res, err := s.userFinder.FindByICQEmail(req.Email)
 
 	switch {
 	case errors.Is(err, state.ErrNoUser):
 		resp.Success = wire.ICQStatusCodeFail
 	case err != nil:
-		s.logger.Error("FindByEmail failed", "err", err.Error())
+		s.logger.Error("FindByICQEmail failed", "err", err.Error())
 		resp.Success = wire.ICQStatusCodeErr
 	default:
 		resp.Success = wire.ICQStatusCodeOK
@@ -150,13 +150,13 @@ func (s ICQService) FindByEmail3(ctx context.Context, sess *state.Session, req w
 	}
 	resp.LastResult()
 
-	res, err := s.userFinder.FindByEmail(email.Email)
+	res, err := s.userFinder.FindByICQEmail(email.Email)
 
 	switch {
 	case errors.Is(err, state.ErrNoUser):
 		resp.Success = wire.ICQStatusCodeFail
 	case err != nil:
-		s.logger.Error("FindByEmail failed", "err", err.Error())
+		s.logger.Error("FindByICQEmail failed", "err", err.Error())
 		resp.Success = wire.ICQStatusCodeErr
 	default:
 		resp.Success = wire.ICQStatusCodeOK
@@ -168,7 +168,7 @@ func (s ICQService) FindByEmail3(ctx context.Context, sess *state.Session, req w
 	})
 }
 
-func (s ICQService) FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
+func (s ICQService) FindByICQInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     sess.UIN(),
@@ -180,10 +180,10 @@ func (s ICQService) FindByInterests(ctx context.Context, sess *state.Session, re
 	}
 
 	interests := strings.Split(req.InterestsKeyword, ",")
-	res, err := s.userFinder.FindByInterests(req.InterestsCode, interests)
+	res, err := s.userFinder.FindByICQInterests(req.InterestsCode, interests)
 
 	if err != nil {
-		s.logger.Error("FindByInterests failed", "err", err.Error())
+		s.logger.Error("FindByICQInterests failed", "err", err.Error())
 		resp.Success = wire.ICQStatusCodeErr
 		return s.reply(ctx, sess, wire.ICQMessageReplyEnvelope{
 			Message: resp,
@@ -217,9 +217,9 @@ func (s ICQService) FindByWhitePages2(ctx context.Context, sess *state.Session,
 
 	users, err := func() ([]state.User, error) {
 		if keyword, hasKeyword := req.ICQString(wire.ICQTLVTagsWhitepagesSearchKeywords); hasKeyword {
-			res, err := s.userFinder.FindByKeyword(keyword)
+			res, err := s.userFinder.FindByICQKeyword(keyword)
 			if err != nil {
-				return nil, fmt.Errorf("FindByKeyword failed: %w", err)
+				return nil, fmt.Errorf("FindByICQKeyword failed: %w", err)
 			}
 			return res, nil
 		}
@@ -229,9 +229,9 @@ func (s ICQService) FindByWhitePages2(ctx context.Context, sess *state.Session,
 		bLast, hastLast := req.ICQString(wire.ICQTLVTagsLastName)
 
 		if hasNick || hasFirst || hastLast {
-			res, err := s.userFinder.FindByDetails(bFirst, bLast, bNick)
+			res, err := s.userFinder.FindByICQName(bFirst, bLast, bNick)
 			if err != nil {
-				return nil, fmt.Errorf("FindByDetails failed: %w", err)
+				return nil, fmt.Errorf("FindByICQName failed: %w", err)
 			}
 			return res, nil
 		}

+ 11 - 11
foodgroup/icq_test.go

@@ -52,7 +52,7 @@ func TestICQService_DeleteMsgReq(t *testing.T) {
 	}
 }
 
-func TestICQService_FindByDetails(t *testing.T) {
+func TestICQService_FindByICQName(t *testing.T) {
 	tests := []struct {
 		name       string
 		timeNow    func() time.Time
@@ -225,7 +225,7 @@ func TestICQService_FindByDetails(t *testing.T) {
 			userFinder := newMockICQUserFinder(t)
 			for _, params := range tt.mockParams.findByDetailsParams {
 				userFinder.EXPECT().
-					FindByDetails(params.firstName, params.lastName, params.nickName).
+					FindByICQName(params.firstName, params.lastName, params.nickName).
 					Return(params.result, params.err)
 			}
 
@@ -247,13 +247,13 @@ func TestICQService_FindByDetails(t *testing.T) {
 				timeNow:          tt.timeNow,
 				userFinder:       userFinder,
 			}
-			err := s.FindByDetails(nil, tt.sess, tt.req, tt.seq)
+			err := s.FindByICQName(nil, tt.sess, tt.req, tt.seq)
 			assert.NoError(t, err)
 		})
 	}
 }
 
-func TestICQService_FindByEmail(t *testing.T) {
+func TestICQService_FindByICQEmail(t *testing.T) {
 	tests := []struct {
 		name       string
 		timeNow    func() time.Time
@@ -361,7 +361,7 @@ func TestICQService_FindByEmail(t *testing.T) {
 			userFinder := newMockICQUserFinder(t)
 			for _, params := range tt.mockParams.findByEmailParams {
 				userFinder.EXPECT().
-					FindByEmail(params.email).
+					FindByICQEmail(params.email).
 					Return(params.result, params.err)
 			}
 
@@ -383,7 +383,7 @@ func TestICQService_FindByEmail(t *testing.T) {
 				timeNow:          tt.timeNow,
 				userFinder:       userFinder,
 			}
-			err := s.FindByEmail(nil, tt.sess, tt.req, tt.seq)
+			err := s.FindByICQEmail(nil, tt.sess, tt.req, tt.seq)
 			assert.NoError(t, err)
 		})
 	}
@@ -503,7 +503,7 @@ func TestICQService_FindByEmail3(t *testing.T) {
 			userFinder := newMockICQUserFinder(t)
 			for _, params := range tt.mockParams.findByEmailParams {
 				userFinder.EXPECT().
-					FindByEmail(params.email).
+					FindByICQEmail(params.email).
 					Return(params.result, params.err)
 			}
 
@@ -978,7 +978,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 			userFinder := newMockICQUserFinder(t)
 			for _, params := range tt.mockParams.findByInterestsParams {
 				userFinder.EXPECT().
-					FindByInterests(params.code, params.keywords).
+					FindByICQInterests(params.code, params.keywords).
 					Return(params.result, params.err)
 			}
 
@@ -1000,7 +1000,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 				timeNow:          tt.timeNow,
 				userFinder:       userFinder,
 			}
-			err := s.FindByInterests(nil, tt.sess, tt.req, tt.seq)
+			err := s.FindByICQInterests(nil, tt.sess, tt.req, tt.seq)
 			assert.NoError(t, err)
 		})
 	}
@@ -1297,12 +1297,12 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 			userFinder := newMockICQUserFinder(t)
 			for _, params := range tt.mockParams.findByKeywordParams {
 				userFinder.EXPECT().
-					FindByKeyword(params.keyword).
+					FindByICQKeyword(params.keyword).
 					Return(params.result, params.err)
 			}
 			for _, params := range tt.mockParams.findByDetailsParams {
 				userFinder.EXPECT().
-					FindByDetails(params.firstName, params.lastName, params.nickName).
+					FindByICQName(params.firstName, params.lastName, params.nickName).
 					Return(params.result, params.err)
 			}
 

+ 30 - 6
foodgroup/locate.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"context"
 	"errors"
+	"fmt"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -180,9 +181,14 @@ func (s LocateService) UserInfoQuery(_ context.Context, sess *state.Session, inF
 }
 
 // SetDirInfo sets directory information for current user (first name, last
-// name, etc). This method does nothing and exists to placate the AIM client.
-// It returns wire.LocateSetDirReply with a canned success message.
-func (s LocateService) SetDirInfo(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+// name, etc).
+func (s LocateService) SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error) {
+	info := newAIMNameAndAddrFromTLVList(inBody.TLVList)
+
+	if err := s.profileManager.SetDirectoryInfo(sess.IdentScreenName(), info); err != nil {
+		return wire.SNACMessage{}, err
+	}
+
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Locate,
@@ -192,13 +198,31 @@ func (s LocateService) SetDirInfo(_ context.Context, inFrame wire.SNACFrame) wir
 		Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
 			Result: 1,
 		},
-	}
+	}, nil
 }
 
 // SetKeywordInfo sets profile keywords and interests. This method does nothing
 // and exists to placate the AIM client. It returns wire.LocateSetKeywordReply
 // with a canned success message.
-func (s LocateService) SetKeywordInfo(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+func (s LocateService) SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error) {
+	var keywords [5]string
+
+	i := 0
+	for _, tlv := range body.TLVList {
+		if tlv.Tag != wire.ODirTLVInterest {
+			continue
+		}
+		keywords[i] = string(tlv.Value)
+		i++
+		if i == len(body.TLVList) {
+			break
+		}
+	}
+
+	if err := s.profileManager.SetKeywords(sess.IdentScreenName(), keywords); err != nil {
+		return wire.SNACMessage{}, fmt.Errorf("SetKeywords: %w", err)
+	}
+
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Locate,
@@ -208,5 +232,5 @@ func (s LocateService) SetKeywordInfo(_ context.Context, inFrame wire.SNACFrame)
 		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
 			Unknown: 1,
 		},
-	}
+	}, nil
 }

+ 160 - 26
foodgroup/locate_test.go

@@ -363,39 +363,173 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 }
 
 func TestLocateService_SetKeywordInfo(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil, nil)
-
-	outputSNAC := svc.SetKeywordInfo(nil, wire.SNACFrame{RequestID: 1234})
-	expectSNAC := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetKeywordReply,
-			RequestID: 1234,
-		},
-		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
-			Unknown: 1,
+	tests := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user setting info
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from client to server
+		inputSNAC wire.SNACMessage
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// wantErr is the expected error
+		wantErr error
+	}{
+		{
+			name:        "set keyword info",
+			userSession: newTestSession("test-user"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVInterest, "interest1"),
+							wire.NewTLVBE(wire.ODirTLVFirstName, "first_name"),
+							wire.NewTLVBE(wire.ODirTLVInterest, "interest2"),
+							wire.NewTLVBE(wire.ODirTLVLastName, "last_name"),
+							wire.NewTLVBE(wire.ODirTLVInterest, "interest3"),
+							wire.NewTLVBE(wire.ODirTLVInterest, "interest4"),
+							wire.NewTLVBE(wire.ODirTLVInterest, "interest5"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateSetKeywordReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
+					Unknown: 1,
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					setKeywordsParams: setKeywordsParams{
+						{
+							screenName: state.NewIdentScreenName("test-user"),
+							keywords: [5]string{
+								"interest1",
+								"interest2",
+								"interest3",
+								"interest4",
+								"interest5",
+							},
+						},
+					},
+				},
+			},
 		},
 	}
-
-	assert.Equal(t, expectSNAC, outputSNAC)
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			profileManager := newMockProfileManager(t)
+			for _, params := range tt.mockParams.setKeywordsParams {
+				profileManager.EXPECT().
+					SetKeywords(params.screenName, params.keywords).
+					Return(params.err)
+			}
+			svc := NewLocateService(nil, nil, profileManager, nil)
+			outputSNAC, err := svc.SetKeywordInfo(nil, tt.userSession, tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x02_0x0F_LocateSetKeywordInfo))
+			assert.NoError(t, err)
+			assert.Equal(t, tt.expectOutput, outputSNAC)
+		})
+	}
 }
 
 func TestLocateService_SetDirInfo(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil, nil)
-
-	outputSNAC := svc.SetDirInfo(nil, wire.SNACFrame{RequestID: 1234})
-	expectSNAC := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetDirReply,
-			RequestID: 1234,
-		},
-		Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
-			Result: 1,
+	tests := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user setting info
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from client to server
+		inputSNAC wire.SNACMessage
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// wantErr is the expected error
+		wantErr error
+	}{
+		{
+			name:        "set directory info",
+			userSession: newTestSession("test-user"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVFirstName, "first_name"),
+							wire.NewTLVBE(wire.ODirTLVLastName, "last_name"),
+							wire.NewTLVBE(wire.ODirTLVMiddleName, "middle_name"),
+							wire.NewTLVBE(wire.ODirTLVMaidenName, "maiden_name"),
+							wire.NewTLVBE(wire.ODirTLVCountry, "country"),
+							wire.NewTLVBE(wire.ODirTLVState, "state"),
+							wire.NewTLVBE(wire.ODirTLVCity, "city"),
+							wire.NewTLVBE(wire.ODirTLVNickName, "nick_name"),
+							wire.NewTLVBE(wire.ODirTLVZIP, "zip"),
+							wire.NewTLVBE(wire.ODirTLVAddress, "address"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateSetDirReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
+					Result: 1,
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					setDirectoryInfoParams: setDirectoryInfoParams{
+						{
+							screenName: state.NewIdentScreenName("test-user"),
+							info: state.AIMNameAndAddr{
+								FirstName:  "first_name",
+								LastName:   "last_name",
+								MiddleName: "middle_name",
+								MaidenName: "maiden_name",
+								Country:    "country",
+								State:      "state",
+								City:       "city",
+								NickName:   "nick_name",
+								ZIPCode:    "zip",
+								Address:    "address",
+							},
+						},
+					},
+				},
+			},
 		},
 	}
-
-	assert.Equal(t, expectSNAC, outputSNAC)
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			profileManager := newMockProfileManager(t)
+			for _, params := range tt.mockParams.setDirectoryInfoParams {
+				profileManager.EXPECT().
+					SetDirectoryInfo(params.screenName, params.info).
+					Return(nil)
+			}
+			svc := NewLocateService(nil, nil, profileManager, nil)
+			outputSNAC, err := svc.SetDirInfo(nil, tt.userSession, tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x02_0x09_LocateSetDirInfo))
+			assert.NoError(t, err)
+			assert.Equal(t, tt.expectOutput, outputSNAC)
+		})
+	}
 }
 
 func TestLocateService_SetInfo(t *testing.T) {

+ 89 - 89
foodgroup/mock_icq_user_finder_test.go

@@ -20,29 +20,27 @@ func (_m *mockICQUserFinder) EXPECT() *mockICQUserFinder_Expecter {
 	return &mockICQUserFinder_Expecter{mock: &_m.Mock}
 }
 
-// FindByDetails provides a mock function with given fields: firstName, lastName, nickName
-func (_m *mockICQUserFinder) FindByDetails(firstName string, lastName string, nickName string) ([]state.User, error) {
-	ret := _m.Called(firstName, lastName, nickName)
+// FindByICQEmail provides a mock function with given fields: email
+func (_m *mockICQUserFinder) FindByICQEmail(email string) (state.User, error) {
+	ret := _m.Called(email)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByDetails")
+		panic("no return value specified for FindByICQEmail")
 	}
 
-	var r0 []state.User
+	var r0 state.User
 	var r1 error
-	if rf, ok := ret.Get(0).(func(string, string, string) ([]state.User, error)); ok {
-		return rf(firstName, lastName, nickName)
+	if rf, ok := ret.Get(0).(func(string) (state.User, error)); ok {
+		return rf(email)
 	}
-	if rf, ok := ret.Get(0).(func(string, string, string) []state.User); ok {
-		r0 = rf(firstName, lastName, nickName)
+	if rf, ok := ret.Get(0).(func(string) state.User); ok {
+		r0 = rf(email)
 	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]state.User)
-		}
+		r0 = ret.Get(0).(state.User)
 	}
 
-	if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
-		r1 = rf(firstName, lastName, nickName)
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(email)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -50,57 +48,57 @@ func (_m *mockICQUserFinder) FindByDetails(firstName string, lastName string, ni
 	return r0, r1
 }
 
-// mockICQUserFinder_FindByDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByDetails'
-type mockICQUserFinder_FindByDetails_Call struct {
+// mockICQUserFinder_FindByICQEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQEmail'
+type mockICQUserFinder_FindByICQEmail_Call struct {
 	*mock.Call
 }
 
-// FindByDetails is a helper method to define mock.On call
-//   - firstName string
-//   - lastName string
-//   - nickName string
-func (_e *mockICQUserFinder_Expecter) FindByDetails(firstName interface{}, lastName interface{}, nickName interface{}) *mockICQUserFinder_FindByDetails_Call {
-	return &mockICQUserFinder_FindByDetails_Call{Call: _e.mock.On("FindByDetails", firstName, lastName, nickName)}
+// FindByICQEmail is a helper method to define mock.On call
+//   - email string
+func (_e *mockICQUserFinder_Expecter) FindByICQEmail(email interface{}) *mockICQUserFinder_FindByICQEmail_Call {
+	return &mockICQUserFinder_FindByICQEmail_Call{Call: _e.mock.On("FindByICQEmail", email)}
 }
 
-func (_c *mockICQUserFinder_FindByDetails_Call) Run(run func(firstName string, lastName string, nickName string)) *mockICQUserFinder_FindByDetails_Call {
+func (_c *mockICQUserFinder_FindByICQEmail_Call) Run(run func(email string)) *mockICQUserFinder_FindByICQEmail_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string), args[1].(string), args[2].(string))
+		run(args[0].(string))
 	})
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByDetails_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByDetails_Call {
+func (_c *mockICQUserFinder_FindByICQEmail_Call) Return(_a0 state.User, _a1 error) *mockICQUserFinder_FindByICQEmail_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByDetails_Call) RunAndReturn(run func(string, string, string) ([]state.User, error)) *mockICQUserFinder_FindByDetails_Call {
+func (_c *mockICQUserFinder_FindByICQEmail_Call) RunAndReturn(run func(string) (state.User, error)) *mockICQUserFinder_FindByICQEmail_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FindByEmail provides a mock function with given fields: email
-func (_m *mockICQUserFinder) FindByEmail(email string) (state.User, error) {
-	ret := _m.Called(email)
+// FindByICQInterests provides a mock function with given fields: code, keywords
+func (_m *mockICQUserFinder) FindByICQInterests(code uint16, keywords []string) ([]state.User, error) {
+	ret := _m.Called(code, keywords)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByEmail")
+		panic("no return value specified for FindByICQInterests")
 	}
 
-	var r0 state.User
+	var r0 []state.User
 	var r1 error
-	if rf, ok := ret.Get(0).(func(string) (state.User, error)); ok {
-		return rf(email)
+	if rf, ok := ret.Get(0).(func(uint16, []string) ([]state.User, error)); ok {
+		return rf(code, keywords)
 	}
-	if rf, ok := ret.Get(0).(func(string) state.User); ok {
-		r0 = rf(email)
+	if rf, ok := ret.Get(0).(func(uint16, []string) []state.User); ok {
+		r0 = rf(code, keywords)
 	} else {
-		r0 = ret.Get(0).(state.User)
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
 	}
 
-	if rf, ok := ret.Get(1).(func(string) error); ok {
-		r1 = rf(email)
+	if rf, ok := ret.Get(1).(func(uint16, []string) error); ok {
+		r1 = rf(code, keywords)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -108,57 +106,58 @@ func (_m *mockICQUserFinder) FindByEmail(email string) (state.User, error) {
 	return r0, r1
 }
 
-// mockICQUserFinder_FindByEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail'
-type mockICQUserFinder_FindByEmail_Call struct {
+// mockICQUserFinder_FindByICQInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQInterests'
+type mockICQUserFinder_FindByICQInterests_Call struct {
 	*mock.Call
 }
 
-// FindByEmail is a helper method to define mock.On call
-//   - email string
-func (_e *mockICQUserFinder_Expecter) FindByEmail(email interface{}) *mockICQUserFinder_FindByEmail_Call {
-	return &mockICQUserFinder_FindByEmail_Call{Call: _e.mock.On("FindByEmail", email)}
+// FindByICQInterests is a helper method to define mock.On call
+//   - code uint16
+//   - keywords []string
+func (_e *mockICQUserFinder_Expecter) FindByICQInterests(code interface{}, keywords interface{}) *mockICQUserFinder_FindByICQInterests_Call {
+	return &mockICQUserFinder_FindByICQInterests_Call{Call: _e.mock.On("FindByICQInterests", code, keywords)}
 }
 
-func (_c *mockICQUserFinder_FindByEmail_Call) Run(run func(email string)) *mockICQUserFinder_FindByEmail_Call {
+func (_c *mockICQUserFinder_FindByICQInterests_Call) Run(run func(code uint16, keywords []string)) *mockICQUserFinder_FindByICQInterests_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string))
+		run(args[0].(uint16), args[1].([]string))
 	})
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByEmail_Call) Return(_a0 state.User, _a1 error) *mockICQUserFinder_FindByEmail_Call {
+func (_c *mockICQUserFinder_FindByICQInterests_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByICQInterests_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByEmail_Call) RunAndReturn(run func(string) (state.User, error)) *mockICQUserFinder_FindByEmail_Call {
+func (_c *mockICQUserFinder_FindByICQInterests_Call) RunAndReturn(run func(uint16, []string) ([]state.User, error)) *mockICQUserFinder_FindByICQInterests_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FindByInterests provides a mock function with given fields: code, keywords
-func (_m *mockICQUserFinder) FindByInterests(code uint16, keywords []string) ([]state.User, error) {
-	ret := _m.Called(code, keywords)
+// FindByICQKeyword provides a mock function with given fields: keyword
+func (_m *mockICQUserFinder) FindByICQKeyword(keyword string) ([]state.User, error) {
+	ret := _m.Called(keyword)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByInterests")
+		panic("no return value specified for FindByICQKeyword")
 	}
 
 	var r0 []state.User
 	var r1 error
-	if rf, ok := ret.Get(0).(func(uint16, []string) ([]state.User, error)); ok {
-		return rf(code, keywords)
+	if rf, ok := ret.Get(0).(func(string) ([]state.User, error)); ok {
+		return rf(keyword)
 	}
-	if rf, ok := ret.Get(0).(func(uint16, []string) []state.User); ok {
-		r0 = rf(code, keywords)
+	if rf, ok := ret.Get(0).(func(string) []state.User); ok {
+		r0 = rf(keyword)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).([]state.User)
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(uint16, []string) error); ok {
-		r1 = rf(code, keywords)
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(keyword)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -166,58 +165,57 @@ func (_m *mockICQUserFinder) FindByInterests(code uint16, keywords []string) ([]
 	return r0, r1
 }
 
-// mockICQUserFinder_FindByInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByInterests'
-type mockICQUserFinder_FindByInterests_Call struct {
+// mockICQUserFinder_FindByICQKeyword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQKeyword'
+type mockICQUserFinder_FindByICQKeyword_Call struct {
 	*mock.Call
 }
 
-// FindByInterests is a helper method to define mock.On call
-//   - code uint16
-//   - keywords []string
-func (_e *mockICQUserFinder_Expecter) FindByInterests(code interface{}, keywords interface{}) *mockICQUserFinder_FindByInterests_Call {
-	return &mockICQUserFinder_FindByInterests_Call{Call: _e.mock.On("FindByInterests", code, keywords)}
+// FindByICQKeyword is a helper method to define mock.On call
+//   - keyword string
+func (_e *mockICQUserFinder_Expecter) FindByICQKeyword(keyword interface{}) *mockICQUserFinder_FindByICQKeyword_Call {
+	return &mockICQUserFinder_FindByICQKeyword_Call{Call: _e.mock.On("FindByICQKeyword", keyword)}
 }
 
-func (_c *mockICQUserFinder_FindByInterests_Call) Run(run func(code uint16, keywords []string)) *mockICQUserFinder_FindByInterests_Call {
+func (_c *mockICQUserFinder_FindByICQKeyword_Call) Run(run func(keyword string)) *mockICQUserFinder_FindByICQKeyword_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(uint16), args[1].([]string))
+		run(args[0].(string))
 	})
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByInterests_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByInterests_Call {
+func (_c *mockICQUserFinder_FindByICQKeyword_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByICQKeyword_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByInterests_Call) RunAndReturn(run func(uint16, []string) ([]state.User, error)) *mockICQUserFinder_FindByInterests_Call {
+func (_c *mockICQUserFinder_FindByICQKeyword_Call) RunAndReturn(run func(string) ([]state.User, error)) *mockICQUserFinder_FindByICQKeyword_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FindByKeyword provides a mock function with given fields: keyword
-func (_m *mockICQUserFinder) FindByKeyword(keyword string) ([]state.User, error) {
-	ret := _m.Called(keyword)
+// FindByICQName provides a mock function with given fields: firstName, lastName, nickName
+func (_m *mockICQUserFinder) FindByICQName(firstName string, lastName string, nickName string) ([]state.User, error) {
+	ret := _m.Called(firstName, lastName, nickName)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByKeyword")
+		panic("no return value specified for FindByICQName")
 	}
 
 	var r0 []state.User
 	var r1 error
-	if rf, ok := ret.Get(0).(func(string) ([]state.User, error)); ok {
-		return rf(keyword)
+	if rf, ok := ret.Get(0).(func(string, string, string) ([]state.User, error)); ok {
+		return rf(firstName, lastName, nickName)
 	}
-	if rf, ok := ret.Get(0).(func(string) []state.User); ok {
-		r0 = rf(keyword)
+	if rf, ok := ret.Get(0).(func(string, string, string) []state.User); ok {
+		r0 = rf(firstName, lastName, nickName)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).([]state.User)
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(string) error); ok {
-		r1 = rf(keyword)
+	if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
+		r1 = rf(firstName, lastName, nickName)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -225,30 +223,32 @@ func (_m *mockICQUserFinder) FindByKeyword(keyword string) ([]state.User, error)
 	return r0, r1
 }
 
-// mockICQUserFinder_FindByKeyword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByKeyword'
-type mockICQUserFinder_FindByKeyword_Call struct {
+// mockICQUserFinder_FindByICQName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQName'
+type mockICQUserFinder_FindByICQName_Call struct {
 	*mock.Call
 }
 
-// FindByKeyword is a helper method to define mock.On call
-//   - keyword string
-func (_e *mockICQUserFinder_Expecter) FindByKeyword(keyword interface{}) *mockICQUserFinder_FindByKeyword_Call {
-	return &mockICQUserFinder_FindByKeyword_Call{Call: _e.mock.On("FindByKeyword", keyword)}
+// FindByICQName is a helper method to define mock.On call
+//   - firstName string
+//   - lastName string
+//   - nickName string
+func (_e *mockICQUserFinder_Expecter) FindByICQName(firstName interface{}, lastName interface{}, nickName interface{}) *mockICQUserFinder_FindByICQName_Call {
+	return &mockICQUserFinder_FindByICQName_Call{Call: _e.mock.On("FindByICQName", firstName, lastName, nickName)}
 }
 
-func (_c *mockICQUserFinder_FindByKeyword_Call) Run(run func(keyword string)) *mockICQUserFinder_FindByKeyword_Call {
+func (_c *mockICQUserFinder_FindByICQName_Call) Run(run func(firstName string, lastName string, nickName string)) *mockICQUserFinder_FindByICQName_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(string))
+		run(args[0].(string), args[1].(string), args[2].(string))
 	})
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByKeyword_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByKeyword_Call {
+func (_c *mockICQUserFinder_FindByICQName_Call) Return(_a0 []state.User, _a1 error) *mockICQUserFinder_FindByICQName_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockICQUserFinder_FindByKeyword_Call) RunAndReturn(run func(string) ([]state.User, error)) *mockICQUserFinder_FindByKeyword_Call {
+func (_c *mockICQUserFinder_FindByICQName_Call) RunAndReturn(run func(string, string, string) ([]state.User, error)) *mockICQUserFinder_FindByICQName_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 325 - 0
foodgroup/mock_profile_manager_test.go

@@ -5,6 +5,8 @@ package foodgroup
 import (
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockProfileManager is an autogenerated mock type for the ProfileManager type
@@ -20,6 +22,235 @@ func (_m *mockProfileManager) EXPECT() *mockProfileManager_Expecter {
 	return &mockProfileManager_Expecter{mock: &_m.Mock}
 }
 
+// FindByAIMEmail provides a mock function with given fields: email
+func (_m *mockProfileManager) FindByAIMEmail(email string) (state.User, error) {
+	ret := _m.Called(email)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByAIMEmail")
+	}
+
+	var r0 state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string) (state.User, error)); ok {
+		return rf(email)
+	}
+	if rf, ok := ret.Get(0).(func(string) state.User); ok {
+		r0 = rf(email)
+	} else {
+		r0 = ret.Get(0).(state.User)
+	}
+
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(email)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockProfileManager_FindByAIMEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByAIMEmail'
+type mockProfileManager_FindByAIMEmail_Call struct {
+	*mock.Call
+}
+
+// FindByAIMEmail is a helper method to define mock.On call
+//   - email string
+func (_e *mockProfileManager_Expecter) FindByAIMEmail(email interface{}) *mockProfileManager_FindByAIMEmail_Call {
+	return &mockProfileManager_FindByAIMEmail_Call{Call: _e.mock.On("FindByAIMEmail", email)}
+}
+
+func (_c *mockProfileManager_FindByAIMEmail_Call) Run(run func(email string)) *mockProfileManager_FindByAIMEmail_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockProfileManager_FindByAIMEmail_Call) Return(_a0 state.User, _a1 error) *mockProfileManager_FindByAIMEmail_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockProfileManager_FindByAIMEmail_Call) RunAndReturn(run func(string) (state.User, error)) *mockProfileManager_FindByAIMEmail_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByAIMKeyword provides a mock function with given fields: keyword
+func (_m *mockProfileManager) FindByAIMKeyword(keyword string) ([]state.User, error) {
+	ret := _m.Called(keyword)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByAIMKeyword")
+	}
+
+	var r0 []state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string) ([]state.User, error)); ok {
+		return rf(keyword)
+	}
+	if rf, ok := ret.Get(0).(func(string) []state.User); ok {
+		r0 = rf(keyword)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(keyword)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockProfileManager_FindByAIMKeyword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByAIMKeyword'
+type mockProfileManager_FindByAIMKeyword_Call struct {
+	*mock.Call
+}
+
+// FindByAIMKeyword is a helper method to define mock.On call
+//   - keyword string
+func (_e *mockProfileManager_Expecter) FindByAIMKeyword(keyword interface{}) *mockProfileManager_FindByAIMKeyword_Call {
+	return &mockProfileManager_FindByAIMKeyword_Call{Call: _e.mock.On("FindByAIMKeyword", keyword)}
+}
+
+func (_c *mockProfileManager_FindByAIMKeyword_Call) Run(run func(keyword string)) *mockProfileManager_FindByAIMKeyword_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockProfileManager_FindByAIMKeyword_Call) Return(_a0 []state.User, _a1 error) *mockProfileManager_FindByAIMKeyword_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockProfileManager_FindByAIMKeyword_Call) RunAndReturn(run func(string) ([]state.User, error)) *mockProfileManager_FindByAIMKeyword_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FindByAIMNameAndAddr provides a mock function with given fields: info
+func (_m *mockProfileManager) FindByAIMNameAndAddr(info state.AIMNameAndAddr) ([]state.User, error) {
+	ret := _m.Called(info)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FindByAIMNameAndAddr")
+	}
+
+	var r0 []state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.AIMNameAndAddr) ([]state.User, error)); ok {
+		return rf(info)
+	}
+	if rf, ok := ret.Get(0).(func(state.AIMNameAndAddr) []state.User); ok {
+		r0 = rf(info)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.AIMNameAndAddr) error); ok {
+		r1 = rf(info)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockProfileManager_FindByAIMNameAndAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByAIMNameAndAddr'
+type mockProfileManager_FindByAIMNameAndAddr_Call struct {
+	*mock.Call
+}
+
+// FindByAIMNameAndAddr is a helper method to define mock.On call
+//   - info state.AIMNameAndAddr
+func (_e *mockProfileManager_Expecter) FindByAIMNameAndAddr(info interface{}) *mockProfileManager_FindByAIMNameAndAddr_Call {
+	return &mockProfileManager_FindByAIMNameAndAddr_Call{Call: _e.mock.On("FindByAIMNameAndAddr", info)}
+}
+
+func (_c *mockProfileManager_FindByAIMNameAndAddr_Call) Run(run func(info state.AIMNameAndAddr)) *mockProfileManager_FindByAIMNameAndAddr_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.AIMNameAndAddr))
+	})
+	return _c
+}
+
+func (_c *mockProfileManager_FindByAIMNameAndAddr_Call) Return(_a0 []state.User, _a1 error) *mockProfileManager_FindByAIMNameAndAddr_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockProfileManager_FindByAIMNameAndAddr_Call) RunAndReturn(run func(state.AIMNameAndAddr) ([]state.User, error)) *mockProfileManager_FindByAIMNameAndAddr_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// InterestList provides a mock function with given fields:
+func (_m *mockProfileManager) InterestList() ([]wire.ODirKeywordListItem, error) {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for InterestList")
+	}
+
+	var r0 []wire.ODirKeywordListItem
+	var r1 error
+	if rf, ok := ret.Get(0).(func() ([]wire.ODirKeywordListItem, error)); ok {
+		return rf()
+	}
+	if rf, ok := ret.Get(0).(func() []wire.ODirKeywordListItem); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.ODirKeywordListItem)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockProfileManager_InterestList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InterestList'
+type mockProfileManager_InterestList_Call struct {
+	*mock.Call
+}
+
+// InterestList is a helper method to define mock.On call
+func (_e *mockProfileManager_Expecter) InterestList() *mockProfileManager_InterestList_Call {
+	return &mockProfileManager_InterestList_Call{Call: _e.mock.On("InterestList")}
+}
+
+func (_c *mockProfileManager_InterestList_Call) Run(run func()) *mockProfileManager_InterestList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *mockProfileManager_InterestList_Call) Return(_a0 []wire.ODirKeywordListItem, _a1 error) *mockProfileManager_InterestList_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockProfileManager_InterestList_Call) RunAndReturn(run func() ([]wire.ODirKeywordListItem, error)) *mockProfileManager_InterestList_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // Profile provides a mock function with given fields: screenName
 func (_m *mockProfileManager) Profile(screenName state.IdentScreenName) (string, error) {
 	ret := _m.Called(screenName)
@@ -76,6 +307,100 @@ func (_c *mockProfileManager_Profile_Call) RunAndReturn(run func(state.IdentScre
 	return _c
 }
 
+// SetDirectoryInfo provides a mock function with given fields: name, info
+func (_m *mockProfileManager) SetDirectoryInfo(name state.IdentScreenName, info state.AIMNameAndAddr) error {
+	ret := _m.Called(name, info)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetDirectoryInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, state.AIMNameAndAddr) error); ok {
+		r0 = rf(name, info)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockProfileManager_SetDirectoryInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDirectoryInfo'
+type mockProfileManager_SetDirectoryInfo_Call struct {
+	*mock.Call
+}
+
+// SetDirectoryInfo is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - info state.AIMNameAndAddr
+func (_e *mockProfileManager_Expecter) SetDirectoryInfo(name interface{}, info interface{}) *mockProfileManager_SetDirectoryInfo_Call {
+	return &mockProfileManager_SetDirectoryInfo_Call{Call: _e.mock.On("SetDirectoryInfo", name, info)}
+}
+
+func (_c *mockProfileManager_SetDirectoryInfo_Call) Run(run func(name state.IdentScreenName, info state.AIMNameAndAddr)) *mockProfileManager_SetDirectoryInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(state.AIMNameAndAddr))
+	})
+	return _c
+}
+
+func (_c *mockProfileManager_SetDirectoryInfo_Call) Return(_a0 error) *mockProfileManager_SetDirectoryInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockProfileManager_SetDirectoryInfo_Call) RunAndReturn(run func(state.IdentScreenName, state.AIMNameAndAddr) error) *mockProfileManager_SetDirectoryInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetKeywords provides a mock function with given fields: name, keywords
+func (_m *mockProfileManager) SetKeywords(name state.IdentScreenName, keywords [5]string) error {
+	ret := _m.Called(name, keywords)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetKeywords")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, [5]string) error); ok {
+		r0 = rf(name, keywords)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockProfileManager_SetKeywords_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetKeywords'
+type mockProfileManager_SetKeywords_Call struct {
+	*mock.Call
+}
+
+// SetKeywords is a helper method to define mock.On call
+//   - name state.IdentScreenName
+//   - keywords [5]string
+func (_e *mockProfileManager_Expecter) SetKeywords(name interface{}, keywords interface{}) *mockProfileManager_SetKeywords_Call {
+	return &mockProfileManager_SetKeywords_Call{Call: _e.mock.On("SetKeywords", name, keywords)}
+}
+
+func (_c *mockProfileManager_SetKeywords_Call) Run(run func(name state.IdentScreenName, keywords [5]string)) *mockProfileManager_SetKeywords_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].([5]string))
+	})
+	return _c
+}
+
+func (_c *mockProfileManager_SetKeywords_Call) Return(_a0 error) *mockProfileManager_SetKeywords_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockProfileManager_SetKeywords_Call) RunAndReturn(run func(state.IdentScreenName, [5]string) error) *mockProfileManager_SetKeywords_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // SetProfile provides a mock function with given fields: screenName, body
 func (_m *mockProfileManager) SetProfile(screenName state.IdentScreenName, body string) error {
 	ret := _m.Called(screenName, body)

+ 200 - 0
foodgroup/odir.go

@@ -0,0 +1,200 @@
+package foodgroup
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// NewODirService creates a new instance of ODirService.
+func NewODirService(logger *slog.Logger, profileManager ProfileManager) ODirService {
+	return ODirService{
+		logger:         logger,
+		profileManager: profileManager,
+	}
+}
+
+// ODirService provides functionality for the ODir food group, which
+// provides functionality for searching the user directory.
+type ODirService struct {
+	logger         *slog.Logger
+	profileManager ProfileManager
+}
+
+// InfoQuery searches the user directory based on the query type: name/address,
+// email, or interest. It dispatches the request to the appropriate search
+// method and returns the search results or an error. The search type is
+// determined by the presence of certain TLVs:
+//
+//   - wire.ODirTLVEmailAddress: Search by email.
+//   - wire.ODirTLVInterest: Search by interest keyword.
+//   - wire.ODirTLVFirstName or wire.ODirTLVLastName: Search by name and address.
+//     First name or last name must be required to search by name and address.
+//
+// AIM 5.x sends wire.ODirTLVSearchType to specify the search type. This TLV is
+// ignored in order to be backwards compatible with older versions that do not
+// send it. It doesn't appear to make a difference, since AIM 5.x sends the
+// same TLV types for each search type.
+func (s ODirService) InfoQuery(_ context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) {
+	snac := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ODir,
+			SubGroup:  wire.ODirInfoReply,
+			RequestID: inFrame.RequestID,
+		},
+	}
+
+	switch {
+	case inBody.HasTag(wire.ODirTLVEmailAddress):
+		var err error
+		snac.Body, err = s.searchByEmail(inBody)
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+	case inBody.HasTag(wire.ODirTLVInterest):
+		var err error
+		snac.Body, err = s.searchByInterest(inBody)
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+	case inBody.HasTag(wire.ODirTLVFirstName), inBody.HasTag(wire.ODirTLVLastName):
+		var err error
+		snac.Body, err = s.searchByNameAndAddr(inBody)
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+	default:
+		snac.Body = wire.SNAC_0x0F_0x03_InfoReply{
+			Status: wire.ODirSearchResponseNameMissing,
+		}
+	}
+
+	return snac, nil
+}
+
+// searchByNameAndAddr performs a directory search using the user's first or
+// last name and address.
+func (s ODirService) searchByNameAndAddr(inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNAC_0x0F_0x03_InfoReply, error) {
+	foundUsers, err := s.profileManager.FindByAIMNameAndAddr(newAIMNameAndAddrFromTLVList(inBody.TLVList))
+	if err != nil {
+		return wire.SNAC_0x0F_0x03_InfoReply{}, fmt.Errorf("FindByAIMNameAndAddr: %w", err)
+	}
+	return s.searchResponse(foundUsers)
+}
+
+// searchByInterest performs a directory search using the user's specified
+// interest keyword.
+func (s ODirService) searchByInterest(inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNAC_0x0F_0x03_InfoReply, error) {
+	var foundUsers []state.User
+
+	interest, _ := inBody.String(wire.ODirTLVInterest)
+
+	foundUsers, err := s.profileManager.FindByAIMKeyword(interest)
+	if err != nil {
+		return wire.SNAC_0x0F_0x03_InfoReply{}, fmt.Errorf("FindByAIMKeyword: %w", err)
+	}
+
+	return s.searchResponse(foundUsers)
+}
+
+// searchByEmail performs a directory search using the user's email address.
+func (s ODirService) searchByEmail(inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNAC_0x0F_0x03_InfoReply, error) {
+	email, _ := inBody.String(wire.ODirTLVEmailAddress)
+
+	result, err := s.profileManager.FindByAIMEmail(email)
+	if err != nil {
+		if errors.Is(err, state.ErrNoUser) {
+			return s.searchResponse(nil)
+		}
+		return wire.SNAC_0x0F_0x03_InfoReply{}, fmt.Errorf("FindByAIMEmail: %w", err)
+	}
+
+	return s.searchResponse([]state.User{result})
+}
+
+// searchResponse constructs the SNAC reply based on the users found during the
+// search.
+func (s ODirService) searchResponse(foundUsers []state.User) (wire.SNAC_0x0F_0x03_InfoReply, error) {
+	body := wire.SNAC_0x0F_0x03_InfoReply{
+		Status: wire.ODirSearchResponseOK,
+	}
+
+	for _, res := range foundUsers {
+		body.Results.List = append(body.Results.List, wire.TLVBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ODirTLVFirstName, res.AIMDirectoryInfo.FirstName),
+				wire.NewTLVBE(wire.ODirTLVLastName, res.AIMDirectoryInfo.LastName),
+				wire.NewTLVBE(wire.ODirTLVState, res.AIMDirectoryInfo.State),
+				wire.NewTLVBE(wire.ODirTLVCity, res.AIMDirectoryInfo.City),
+				wire.NewTLVBE(wire.ODirTLVCountry, res.AIMDirectoryInfo.Country),
+				wire.NewTLVBE(wire.ODirTLVScreenName, res.DisplayScreenName.String()),
+			},
+		})
+	}
+
+	return body, nil
+}
+
+// KeywordListQuery returns a list of keywords that can be searched in the user
+// directory.
+func (s ODirService) KeywordListQuery(_ context.Context, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	interests, err := s.profileManager.InterestList()
+	if err != nil {
+		return wire.SNACMessage{}, fmt.Errorf("InterestList: %w", err)
+	}
+
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ODir,
+			SubGroup:  wire.ODirKeywordListReply,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x0F_0x04_KeywordListReply{
+			Status:    0x01,
+			Interests: interests,
+		},
+	}, nil
+}
+
+// newAIMNameAndAddrFromTLVList constructs an AIMNameAndAddr structure from the
+// TLV list containing user directory fields like first name, last name, etc.
+func newAIMNameAndAddrFromTLVList(tlvList wire.TLVList) state.AIMNameAndAddr {
+	a := state.AIMNameAndAddr{}
+
+	if firstName, hasFirstName := tlvList.String(wire.ODirTLVFirstName); hasFirstName {
+		a.FirstName = firstName
+	}
+	if lastName, hasLastName := tlvList.String(wire.ODirTLVLastName); hasLastName {
+		a.LastName = lastName
+	}
+	if middleName, hasMiddleName := tlvList.String(wire.ODirTLVMiddleName); hasMiddleName {
+		a.MiddleName = middleName
+	}
+	if maidenName, hasMaidenName := tlvList.String(wire.ODirTLVMaidenName); hasMaidenName {
+		a.MaidenName = maidenName
+	}
+	if country, hasCountry := tlvList.String(wire.ODirTLVCountry); hasCountry {
+		a.Country = country
+	}
+	if st, hasState := tlvList.String(wire.ODirTLVState); hasState {
+		a.State = st
+	}
+	if city, hasCity := tlvList.String(wire.ODirTLVCity); hasCity {
+		a.City = city
+	}
+	if nickName, hasNickName := tlvList.String(wire.ODirTLVNickName); hasNickName {
+		a.NickName = nickName
+	}
+	if zipCode, hasZIPCode := tlvList.String(wire.ODirTLVZIP); hasZIPCode {
+		a.ZIPCode = zipCode
+	}
+	if address, hasAddress := tlvList.String(wire.ODirTLVAddress); hasAddress {
+		a.Address = address
+	}
+
+	return a
+}

+ 516 - 0
foodgroup/odir_test.go

@@ -0,0 +1,516 @@
+package foodgroup
+
+import (
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestODirService_KeywordListQuery(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC wire.SNACMessage
+		// expectSNACFrame is the SNAC frame sent from the server to the recipient
+		// client
+		expectOutput wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectErr is the expected error returned by the handler
+		expectErr error
+	}{
+		{
+			name: "get full list",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirKeywordListReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x04_KeywordListReply{
+					Status: 0x01,
+					Interests: []wire.ODirKeywordListItem{
+						{
+							ID:   0,
+							Name: "Animals",
+							Type: wire.ODirKeyword,
+						},
+						{
+							ID:   1,
+							Name: "Music",
+							Type: wire.ODirKeywordCategory,
+						},
+						{
+							ID:   1,
+							Name: "Rock",
+							Type: wire.ODirKeyword,
+						},
+						{
+							ID:   1,
+							Name: "Jazz",
+							Type: wire.ODirKeyword,
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					interestListParams: interestListParams{
+						{
+							result: []wire.ODirKeywordListItem{
+								{
+									ID:   0,
+									Name: "Animals",
+									Type: wire.ODirKeyword,
+								},
+								{
+									ID:   1,
+									Name: "Music",
+									Type: wire.ODirKeywordCategory,
+								},
+								{
+									ID:   1,
+									Name: "Rock",
+									Type: wire.ODirKeyword,
+								},
+								{
+									ID:   1,
+									Name: "Jazz",
+									Type: wire.ODirKeyword,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			profileManager := newMockProfileManager(t)
+			for _, params := range tc.mockParams.interestListParams {
+				profileManager.EXPECT().
+					InterestList().
+					Return(params.result, params.err)
+			}
+
+			svc := NewODirService(slog.Default(), profileManager)
+			actual, err := svc.KeywordListQuery(nil, tc.inputSNAC.Frame)
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, actual)
+		})
+	}
+}
+
+func TestODirService_InfoQuery(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// inputSNAC is the SNAC sent by the sender client
+		inputSNAC wire.SNACMessage
+		// expectSNACFrame is the SNAC frame sent from the server to the recipient
+		// client
+		expectOutput wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectErr is the expected error returned by the handler
+		expectErr error
+	}{
+		{
+			name: "search by name and address - results found",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVFirstName, "joe"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseOK,
+					Results: struct {
+						List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+					}{List: []wire.TLVBlock{
+						{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ODirTLVFirstName, "Joe"),
+								wire.NewTLVBE(wire.ODirTLVLastName, "Doe"),
+								wire.NewTLVBE(wire.ODirTLVState, "California"),
+								wire.NewTLVBE(wire.ODirTLVCity, "Los Angeles"),
+								wire.NewTLVBE(wire.ODirTLVCountry, "USA"),
+								wire.NewTLVBE(wire.ODirTLVScreenName, "joe123"),
+							},
+						},
+						{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ODirTLVFirstName, "Joe"),
+								wire.NewTLVBE(wire.ODirTLVLastName, "Smith"),
+								wire.NewTLVBE(wire.ODirTLVState, "New York"),
+								wire.NewTLVBE(wire.ODirTLVCity, "New York City"),
+								wire.NewTLVBE(wire.ODirTLVCountry, "USA"),
+								wire.NewTLVBE(wire.ODirTLVScreenName, "joe321"),
+							},
+						},
+					}},
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMNameAndAddrParams: findByAIMNameAndAddrParams{
+						{
+							info: state.AIMNameAndAddr{
+								FirstName: "joe",
+							},
+							result: []state.User{
+								{
+									DisplayScreenName: "joe123",
+									AIMDirectoryInfo: state.AIMNameAndAddr{
+										FirstName: "Joe",
+										LastName:  "Doe",
+										Country:   "USA",
+										State:     "California",
+										City:      "Los Angeles",
+									},
+								},
+								{
+									DisplayScreenName: "joe321",
+									AIMDirectoryInfo: state.AIMNameAndAddr{
+										FirstName: "Joe",
+										LastName:  "Smith",
+										Country:   "USA",
+										State:     "New York",
+										City:      "New York City",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "search by name and address - no results found",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVFirstName, "joe"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseOK,
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMNameAndAddrParams: findByAIMNameAndAddrParams{
+						{
+							info: state.AIMNameAndAddr{
+								FirstName: "joe",
+							},
+							result: []state.User{},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "search by name and address - no first or last name",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVCity, "new york"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseNameMissing,
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMNameAndAddrParams: findByAIMNameAndAddrParams{},
+				},
+			},
+		},
+		{
+			name: "search by email - results found",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVEmailAddress, "test@aol.com"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseOK,
+					Results: struct {
+						List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+					}{List: []wire.TLVBlock{
+						{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ODirTLVFirstName, "Joe"),
+								wire.NewTLVBE(wire.ODirTLVLastName, "Doe"),
+								wire.NewTLVBE(wire.ODirTLVState, "California"),
+								wire.NewTLVBE(wire.ODirTLVCity, "Los Angeles"),
+								wire.NewTLVBE(wire.ODirTLVCountry, "USA"),
+								wire.NewTLVBE(wire.ODirTLVScreenName, "joe123"),
+							},
+						},
+					}},
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMEmailParams: findByAIMEmailParams{
+						{
+							email: "test@aol.com",
+							result: state.User{
+								DisplayScreenName: "joe123",
+								AIMDirectoryInfo: state.AIMNameAndAddr{
+									FirstName: "Joe",
+									LastName:  "Doe",
+									Country:   "USA",
+									State:     "California",
+									City:      "Los Angeles",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "search by email - no results found",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVEmailAddress, "test@aol.com"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseOK,
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMEmailParams: findByAIMEmailParams{
+						{
+							email: "test@aol.com",
+							err:   state.ErrNoUser,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "search by interest - results found",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVInterest, "Computers"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseOK,
+					Results: struct {
+						List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+					}{List: []wire.TLVBlock{
+						{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ODirTLVFirstName, "Joe"),
+								wire.NewTLVBE(wire.ODirTLVLastName, "Doe"),
+								wire.NewTLVBE(wire.ODirTLVState, "California"),
+								wire.NewTLVBE(wire.ODirTLVCity, "Los Angeles"),
+								wire.NewTLVBE(wire.ODirTLVCountry, "USA"),
+								wire.NewTLVBE(wire.ODirTLVScreenName, "joe123"),
+							},
+						},
+						{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ODirTLVFirstName, "Joe"),
+								wire.NewTLVBE(wire.ODirTLVLastName, "Smith"),
+								wire.NewTLVBE(wire.ODirTLVState, "New York"),
+								wire.NewTLVBE(wire.ODirTLVCity, "New York City"),
+								wire.NewTLVBE(wire.ODirTLVCountry, "USA"),
+								wire.NewTLVBE(wire.ODirTLVScreenName, "joe321"),
+							},
+						},
+					}},
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMKeywordParams: findByAIMKeywordParams{
+						{
+							keyword: "Computers",
+							result: []state.User{
+								{
+									DisplayScreenName: "joe123",
+									AIMDirectoryInfo: state.AIMNameAndAddr{
+										FirstName: "Joe",
+										LastName:  "Doe",
+										Country:   "USA",
+										State:     "California",
+										City:      "Los Angeles",
+									},
+								},
+								{
+									DisplayScreenName: "joe321",
+									AIMDirectoryInfo: state.AIMNameAndAddr{
+										FirstName: "Joe",
+										LastName:  "Smith",
+										Country:   "USA",
+										State:     "New York",
+										City:      "New York City",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "search by interest - no results found",
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x02_InfoQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ODirTLVInterest, "Computers"),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: wire.ODirSearchResponseOK,
+				},
+			},
+			mockParams: mockParams{
+				profileManagerParams: profileManagerParams{
+					findByAIMKeywordParams: findByAIMKeywordParams{
+						{
+							keyword: "Computers",
+							result:  []state.User{},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			profileManager := newMockProfileManager(t)
+			for _, params := range tc.mockParams.findByAIMNameAndAddrParams {
+				profileManager.EXPECT().
+					FindByAIMNameAndAddr(params.info).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.findByAIMEmailParams {
+				profileManager.EXPECT().
+					FindByAIMEmail(params.email).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.findByAIMKeywordParams {
+				profileManager.EXPECT().
+					FindByAIMKeyword(params.keyword).
+					Return(params.result, params.err)
+			}
+
+			svc := NewODirService(slog.Default(), profileManager)
+			actual, err := svc.InfoQuery(nil, tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x0F_0x02_InfoQuery))
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, actual)
+		})
+	}
+}

+ 46 - 0
foodgroup/oservice.go

@@ -344,6 +344,13 @@ func init() {
 			wire.PermitDenyAddTempPermitListEntries,
 			wire.PermitDenyDelTempPermitListEntries,
 		},
+		wire.ODir: {
+			wire.ODirErr,
+			wire.ODirInfoQuery,
+			wire.ODirInfoReply,
+			wire.ODirKeywordListQuery,
+			wire.ODirKeywordListReply,
+		},
 	}
 
 	for _, foodGroup := range []uint16{
@@ -359,6 +366,7 @@ func init() {
 		wire.Alert,
 		wire.ICQ,
 		wire.PermitDeny,
+		wire.ODir,
 	} {
 		subGroups := foodGroupToSubgroup[foodGroup]
 		for _, subGroup := range subGroups {
@@ -724,6 +732,31 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 				},
 			},
 		}, nil
+	case wire.ODir:
+		cookie, err := fnIssueCookie(bosCookie{
+			ScreenName: sess.DisplayScreenName(),
+		})
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceServiceResponse,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.ODirPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
+						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.ODir),
+						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
+						wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+					},
+				},
+			},
+		}, nil
 	default:
 		s.logger.InfoContext(ctx, "client service request for unsupported service", "food_group", wire.FoodGroupName(inBody.FoodGroup))
 		return wire.SNACMessage{
@@ -857,6 +890,19 @@ func NewOServiceServiceForAlert(
 	}
 }
 
+// NewOServiceServiceForODir creates a new instance of OServiceService for the
+// ODir server.
+func NewOServiceServiceForODir(cfg config.Config, logger *slog.Logger) *OServiceService {
+	return &OServiceService{
+		cfg:    cfg,
+		logger: logger,
+		foodGroups: []uint16{
+			wire.ODir,
+			wire.OService,
+		},
+	}
+}
+
 // NewOServiceServiceForAdmin creates a new instance of OServiceService for Admin server.
 func NewOServiceServiceForAdmin(cfg config.Config, logger *slog.Logger, buddyUpdateBroadcaster buddyBroadcaster) *OServiceService {
 	return &OServiceService{

+ 20 - 0
foodgroup/oservice_test.go

@@ -1445,6 +1445,26 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 							FoodGroup: wire.PermitDeny,
 							SubGroup:  wire.PermitDenyDelTempPermitListEntries,
 						},
+						{
+							FoodGroup: wire.ODir,
+							SubGroup:  wire.ODirErr,
+						},
+						{
+							FoodGroup: wire.ODir,
+							SubGroup:  wire.ODirInfoQuery,
+						},
+						{
+							FoodGroup: wire.ODir,
+							SubGroup:  wire.ODirInfoReply,
+						},
+						{
+							FoodGroup: wire.ODir,
+							SubGroup:  wire.ODirKeywordListQuery,
+						},
+						{
+							FoodGroup: wire.ODir,
+							SubGroup:  wire.ODirKeywordListReply,
+						},
 					},
 				},
 			},

+ 53 - 0
foodgroup/test_helpers.go

@@ -361,10 +361,55 @@ type relayToScreenNameParams []struct {
 // profileManagerParams is a helper struct that contains mock parameters for
 // ProfileManager methods
 type profileManagerParams struct {
+	findByAIMEmailParams
+	findByAIMKeywordParams
+	findByAIMNameAndAddrParams
+	interestListParams
 	retrieveProfileParams
+	setDirectoryInfoParams
+	setKeywordsParams
 	setProfileParams
 }
 
+// findByAIMEmailParams is the list of parameters passed at the mock
+// ProfileManager.FindByAIMEmail call site
+type findByAIMEmailParams []struct {
+	email  string
+	result state.User
+	err    error
+}
+
+// findByAIMKeywordParams is the list of parameters passed at the mock
+// ProfileManager.FindByAIMKeyword call site
+type findByAIMKeywordParams []struct {
+	keyword string
+	result  []state.User
+	err     error
+}
+
+// findByAIMNameAndAddrParams is the list of parameters passed at the mock
+// ProfileManager.FindByAIMNameAndAddr call site
+type findByAIMNameAndAddrParams []struct {
+	info   state.AIMNameAndAddr
+	result []state.User
+	err    error
+}
+
+// interestListParams is the list of parameters passed at the mock
+// ProfileManager.InterestList call site
+type interestListParams []struct {
+	result []wire.ODirKeywordListItem
+	err    error
+}
+
+// setDirectoryInfoParams is the list of parameters passed at the mock
+// ProfileManager.SetDirectoryInfo call site
+type setDirectoryInfoParams []struct {
+	screenName state.IdentScreenName
+	info       state.AIMNameAndAddr
+	err        error
+}
+
 // retrieveProfileParams is the list of parameters passed at the mock
 // ProfileManager.Profile call site
 type retrieveProfileParams []struct {
@@ -380,6 +425,14 @@ type setProfileParams []struct {
 	body       any
 }
 
+// setKeywordsParams is the list of parameters passed at the mock
+// ProfileManager.SetKeywords call site
+type setKeywordsParams []struct {
+	screenName state.IdentScreenName
+	keywords   [5]string
+	err        error
+}
+
 // chatMessageRelayerParams is a helper struct that contains mock parameters
 // for ChatMessageRelayer methods
 type chatMessageRelayerParams struct {

+ 15 - 10
foodgroup/types.go

@@ -88,7 +88,13 @@ type SessionManager interface {
 }
 
 type ProfileManager interface {
+	FindByAIMEmail(email string) (state.User, error)
+	FindByAIMKeyword(keyword string) ([]state.User, error)
+	FindByAIMNameAndAddr(info state.AIMNameAndAddr) ([]state.User, error)
+	InterestList() ([]wire.ODirKeywordListItem, error)
 	Profile(screenName state.IdentScreenName) (string, error)
+	SetDirectoryInfo(name state.IdentScreenName, info state.AIMNameAndAddr) error
+	SetKeywords(name state.IdentScreenName, keywords [5]string) error
 	SetProfile(screenName state.IdentScreenName, body string) error
 }
 
@@ -177,18 +183,17 @@ type AccountManager interface {
 type ICQUserFinder interface {
 	// FindByUIN returns a user with a matching UIN.
 	FindByUIN(UIN uint32) (state.User, error)
-	// FindByEmail returns a user with a matching email address.
-	FindByEmail(email string) (state.User, error)
-	// FindByDetails returns users with either a matching first name, last
-	// name, and nickname. Empty values are not included in the search
-	// parameters.
-	FindByDetails(firstName, lastName, nickName string) ([]state.User, error)
-	// FindByInterests returns users who have at least one matching interest
+	// FindByICQEmail returns a user with a matching email address.
+	FindByICQEmail(email string) (state.User, error)
+	// FindByICQName returns users with matching first name, last name, and
+	// nickname. Empty values are not included in the search parameters.
+	FindByICQName(firstName, lastName, nickName string) ([]state.User, error)
+	// FindByICQInterests returns users who have at least one matching interest
 	// for a given category code.
-	FindByInterests(code uint16, keywords []string) ([]state.User, error)
-	// FindByKeyword returns users with matching interest keyword across all
+	FindByICQInterests(code uint16, keywords []string) ([]state.User, error)
+	// FindByICQKeyword returns users with matching interest keyword across all
 	// interest categories.
-	FindByKeyword(keyword string) ([]state.User, error)
+	FindByICQKeyword(keyword string) ([]state.User, error)
 }
 
 type ICQUserUpdater interface {

+ 206 - 0
server/http/mgmt_api.go

@@ -10,6 +10,7 @@ import (
 	"net"
 	"net/http"
 	"os"
+	"strconv"
 	"strings"
 	"time"
 
@@ -28,6 +29,7 @@ func StartManagementAPI(
 	chatRoomRetriever ChatRoomRetriever,
 	chatRoomCreator ChatRoomCreator,
 	chatSessionRetriever ChatSessionRetriever,
+	directoryManager DirectoryManager,
 	messageRelayer MessageRelayer,
 	bartRetriever BARTRetriever,
 	feedbagRetriever FeedBagRetriever,
@@ -102,6 +104,34 @@ func StartManagementAPI(
 		getVersionHandler(w, bld)
 	})
 
+	// Handlers for '/directory/category' route
+	mux.HandleFunc("GET /directory/category", func(w http.ResponseWriter, r *http.Request) {
+		getDirectoryCategoryHandler(w, directoryManager, logger)
+	})
+	mux.HandleFunc("POST /directory/category", func(w http.ResponseWriter, r *http.Request) {
+		postDirectoryCategoryHandler(w, r, directoryManager, logger)
+	})
+
+	// Handlers for '/directory/category/{id}' route
+	mux.HandleFunc("DELETE /directory/category/{id}", func(w http.ResponseWriter, r *http.Request) {
+		deleteDirectoryCategoryHandler(w, r, directoryManager, logger)
+	})
+
+	// Handlers for '/directory/category/{id}/keyword' route
+	mux.HandleFunc("GET /directory/category/{id}/keyword", func(w http.ResponseWriter, r *http.Request) {
+		getDirectoryCategoryKeywordHandler(w, r, directoryManager, logger)
+	})
+
+	// Handlers for '/directory/keyword' route
+	mux.HandleFunc("POST /directory/keyword", func(w http.ResponseWriter, r *http.Request) {
+		postDirectoryKeywordHandler(w, r, directoryManager, logger)
+	})
+
+	// Handlers for '/directory/keyword/{id}' route
+	mux.HandleFunc("DELETE /directory/keyword/{id}", func(w http.ResponseWriter, r *http.Request) {
+		deleteDirectoryKeywordHandler(w, r, directoryManager, logger)
+	})
+
 	addr := net.JoinHostPort(cfg.ApiHost, cfg.ApiPort)
 	logger.Info("starting management API server", "addr", addr)
 	if err := http.ListenAndServe(addr, mux); err != nil {
@@ -583,3 +613,179 @@ func getVersionHandler(w http.ResponseWriter, bld config.Build) {
 		return
 	}
 }
+
+// getUserAccountHandler handles the GET /directory/category endpoint.
+func getDirectoryCategoryHandler(w http.ResponseWriter, manager DirectoryManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+	categories, err := manager.Categories()
+	if err != nil {
+		logger.Error("error in GET /directory/category", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	out := make([]directoryCategory, len(categories))
+	for i, category := range categories {
+		out[i] = directoryCategory{
+			ID:   category.ID,
+			Name: category.Name,
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(out); err != nil {
+		errorMsg(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+// postDirectoryCategoryHandler handles the POST /directory/category endpoint.
+func postDirectoryCategoryHandler(w http.ResponseWriter, r *http.Request, manager DirectoryManager, logger *slog.Logger) {
+	input := directoryCategoryCreate{}
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		errorMsg(w, "malformed input", http.StatusBadRequest)
+		return
+	}
+
+	category, err := manager.CreateCategory(input.Name)
+	if err != nil {
+		if errors.Is(err, state.ErrKeywordCategoryExists) {
+			errorMsg(w, "category already exists", http.StatusConflict)
+		} else {
+			logger.Error("error in POST /directory/category", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+		}
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	dc := directoryCategory{
+		ID:   category.ID,
+		Name: category.Name,
+	}
+	if err := json.NewEncoder(w).Encode(dc); err != nil {
+		errorMsg(w, err.Error(), http.StatusBadRequest)
+	}
+}
+
+// deleteDirectoryCategoryHandler handles the DELETE /directory/category/{id} endpoint.
+func deleteDirectoryCategoryHandler(w http.ResponseWriter, r *http.Request, manager DirectoryManager, logger *slog.Logger) {
+	categoryID, err := strconv.ParseUint(r.PathValue("id"), 10, 8)
+	if err != nil {
+		http.Error(w, "invalid category ID", http.StatusBadRequest)
+		return
+	}
+
+	if err := manager.DeleteCategory(uint8(categoryID)); err != nil {
+		switch {
+		case errors.Is(err, state.ErrKeywordCategoryNotFound):
+			errorMsg(w, "category not found", http.StatusNotFound)
+		case errors.Is(err, state.ErrKeywordInUse):
+			errorMsg(w, "can't delete because category in use by a user", http.StatusConflict)
+		default:
+			logger.Error("error in DELETE /directory/category/{id}", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+		}
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// getDirectoryCategoryKeywordHandler handles the GET /directory/category/{id}/keyword endpoint.
+func getDirectoryCategoryKeywordHandler(w http.ResponseWriter, r *http.Request, manager DirectoryManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	categoryID, err := strconv.ParseUint(r.PathValue("id"), 10, 8)
+	if err != nil {
+		errorMsg(w, "invalid category ID", http.StatusBadRequest)
+		return
+	}
+
+	categories, err := manager.KeywordsByCategory(uint8(categoryID))
+	if err != nil {
+		if errors.Is(err, state.ErrKeywordCategoryNotFound) {
+			errorMsg(w, "category not found", http.StatusNotFound)
+		} else {
+			logger.Error("error in GET /directory/category/{id}/keyword", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+		}
+		return
+	}
+
+	out := make([]directoryCategory, len(categories))
+	for i, category := range categories {
+		out[i] = directoryCategory{
+			ID:   category.ID,
+			Name: category.Name,
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(out); err != nil {
+		errorMsg(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+// postDirectoryKeywordHandler handles the POST /directory/keyword endpoint.
+func postDirectoryKeywordHandler(w http.ResponseWriter, r *http.Request, manager DirectoryManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	input := directoryKeywordCreate{}
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		errorMsg(w, "malformed input", http.StatusBadRequest)
+		return
+	}
+
+	kw, err := manager.CreateKeyword(input.Name, input.CategoryID)
+	if err != nil {
+		switch {
+		case errors.Is(err, state.ErrKeywordCategoryNotFound):
+			errorMsg(w, "category not found", http.StatusNotFound)
+		case errors.Is(err, state.ErrKeywordExists):
+			errorMsg(w, "keyword already exists", http.StatusConflict)
+		default:
+			logger.Error("error in POST /directory/keyword", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+		}
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	dc := directoryKeyword{
+		ID:   kw.ID,
+		Name: kw.Name,
+	}
+	if err := json.NewEncoder(w).Encode(dc); err != nil {
+		errorMsg(w, err.Error(), http.StatusBadRequest)
+	}
+}
+
+// deleteDirectoryKeywordHandler handles the DELETE /directory/keyword/{id} endpoint.
+func deleteDirectoryKeywordHandler(w http.ResponseWriter, r *http.Request, manager DirectoryManager, logger *slog.Logger) {
+	keywordID, err := strconv.ParseUint(r.PathValue("id"), 10, 8)
+	if err != nil {
+		errorMsg(w, "invalid keyword ID", http.StatusBadRequest)
+		return
+	}
+
+	if err := manager.DeleteKeyword(uint8(keywordID)); err != nil {
+		switch {
+		case errors.Is(err, state.ErrKeywordInUse):
+			errorMsg(w, "can't delete because category in use by a user", http.StatusConflict)
+		case errors.Is(err, state.ErrKeywordNotFound):
+			errorMsg(w, "keyword not found", http.StatusNotFound)
+		default:
+			logger.Error("error in DELETE /directory/keyword/{id}", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+		}
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// errorMsg sends an error response message and code.
+func errorMsg(w http.ResponseWriter, error string, code int) {
+	msg := messageBody{Message: error}
+	w.WriteHeader(code)
+	if err := json.NewEncoder(w).Encode(msg); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}

+ 643 - 0
server/http/mgmt_api_test.go

@@ -1,6 +1,8 @@
 package http
 
 import (
+	"errors"
+	"fmt"
 	"io"
 	"log/slog"
 	"net/http"
@@ -1375,3 +1377,644 @@ func TestVersionHandler_GET(t *testing.T) {
 		})
 	}
 }
+
+func TestDirectoryCategoryHandler_GET(t *testing.T) {
+	tt := []struct {
+		name       string
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "no categories",
+			want:       `[]`,
+			statusCode: http.StatusOK,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					categoriesParams: categoriesParams{
+						{
+							result: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "error fetching categories",
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					categoriesParams: categoriesParams{
+						{
+							result: nil,
+							err:    errors.New("error fetching categories"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "fetch some categories",
+			want:       `[{"id":1,"name":"category-1"},{"id":2,"name":"category-2"}]`,
+			statusCode: http.StatusOK,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					categoriesParams: categoriesParams{
+						{
+							result: []state.Category{
+								{
+									ID:   1,
+									Name: "category-1",
+								},
+								{
+									ID:   2,
+									Name: "category-2",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			responseRecorder := httptest.NewRecorder()
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.categoriesParams {
+				directoryManager.EXPECT().
+					Categories().
+					Return(params.result, params.err)
+			}
+
+			getDirectoryCategoryHandler(responseRecorder, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestDirectoryCategoryKeywordHandler_GET(t *testing.T) {
+	tt := []struct {
+		name       string
+		categoryID int
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "category not found",
+			categoryID: 1,
+			want:       `{"message":"category not found"}`,
+			statusCode: http.StatusNotFound,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					keywordsByCategoryParams: keywordsByCategoryParams{
+						{
+							categoryID: 1,
+							result:     nil,
+							err:        state.ErrKeywordCategoryNotFound,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "error fetching keywords by category",
+			categoryID: 1,
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					keywordsByCategoryParams: keywordsByCategoryParams{
+						{
+							categoryID: 1,
+							result:     nil,
+							err:        errors.New("error fetching keywords by category"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "invalid category ID",
+			categoryID: -1,
+			want:       `{"message":"invalid category ID"}`,
+			statusCode: http.StatusBadRequest,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					keywordsByCategoryParams: keywordsByCategoryParams{},
+				},
+			},
+		},
+		{
+			name:       "no keywords",
+			categoryID: 1,
+			want:       `[]`,
+			statusCode: http.StatusOK,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					keywordsByCategoryParams: keywordsByCategoryParams{
+						{
+							categoryID: 1,
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "fetch some keywords by category",
+			categoryID: 1,
+			want:       `[{"id":1,"name":"keyword-1"},{"id":2,"name":"keyword-2"}]`,
+			statusCode: http.StatusOK,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					keywordsByCategoryParams: keywordsByCategoryParams{
+						{
+							categoryID: 1,
+							result: []state.Keyword{
+								{
+									ID:   1,
+									Name: "keyword-1",
+								},
+								{
+									ID:   2,
+									Name: "keyword-2",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/directory/category/%d/keyword", tc.categoryID), nil)
+			request.SetPathValue("id", fmt.Sprintf("%d", tc.categoryID))
+			responseRecorder := httptest.NewRecorder()
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.keywordsByCategoryParams {
+				directoryManager.EXPECT().
+					KeywordsByCategory(params.categoryID).
+					Return(params.result, params.err)
+			}
+
+			getDirectoryCategoryKeywordHandler(responseRecorder, request, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestDirectoryCategoryHandler_DELETE(t *testing.T) {
+	tt := []struct {
+		name       string
+		categoryID int
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "category not found",
+			categoryID: 1,
+			want:       `{"message":"category not found"}`,
+			statusCode: http.StatusNotFound,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteCategoryParams: deleteCategoryParams{
+						{
+							categoryID: 1,
+							err:        state.ErrKeywordCategoryNotFound,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "keyword in use by user",
+			categoryID: 1,
+			want:       `{"message":"can't delete because category in use by a user"}`,
+			statusCode: http.StatusConflict,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteCategoryParams: deleteCategoryParams{
+						{
+							categoryID: 1,
+							err:        state.ErrKeywordInUse,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "runtime error",
+			categoryID: 1,
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteCategoryParams: deleteCategoryParams{
+						{
+							categoryID: 1,
+							err:        errors.New("error deleting keyword"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "successful deletion",
+			categoryID: 1,
+			want:       ``,
+			statusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteCategoryParams: deleteCategoryParams{
+						{
+							categoryID: 1,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "invalid category ID",
+			categoryID: -1,
+			want:       `invalid category ID`,
+			statusCode: http.StatusBadRequest,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteCategoryParams: deleteCategoryParams{},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/directory/category/%d/keyword", tc.categoryID), nil)
+			request.SetPathValue("id", fmt.Sprintf("%d", tc.categoryID))
+			responseRecorder := httptest.NewRecorder()
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.deleteCategoryParams {
+				directoryManager.EXPECT().
+					DeleteCategory(params.categoryID).
+					Return(params.err)
+			}
+
+			deleteDirectoryCategoryHandler(responseRecorder, request, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestDirectoryCategoryHandler_POST(t *testing.T) {
+	tt := []struct {
+		name       string
+		body       string
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "category already exists",
+			body:       `{"name":"the_category"}`,
+			want:       `{"message":"category already exists"}`,
+			statusCode: http.StatusConflict,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createCategoryParams: createCategoryParams{
+						{
+							name: "the_category",
+							err:  state.ErrKeywordCategoryExists,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "runtime error",
+			body:       `{"name":"the_category"}`,
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createCategoryParams: createCategoryParams{
+						{
+							name: "the_category",
+							err:  errors.New("error creating category"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "bad input",
+			body:       `{"name":"the_category"`,
+			want:       `{"message":"malformed input"}`,
+			statusCode: http.StatusBadRequest,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createCategoryParams: createCategoryParams{},
+				},
+			},
+		},
+		{
+			name:       "successful creation",
+			body:       `{"name":"the_category"}`,
+			want:       `{"id":1,"name":"the_category"}`,
+			statusCode: http.StatusCreated,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createCategoryParams: createCategoryParams{
+						{
+							name: "the_category",
+							result: state.Category{
+								ID:   1,
+								Name: "the_category",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodPost, "/directory/category", strings.NewReader(tc.body))
+			responseRecorder := httptest.NewRecorder()
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.createCategoryParams {
+				directoryManager.EXPECT().
+					CreateCategory(params.name).
+					Return(params.result, params.err)
+			}
+
+			postDirectoryCategoryHandler(responseRecorder, request, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestDirectoryKeywordHandler_POST(t *testing.T) {
+	tt := []struct {
+		name       string
+		body       string
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "keyword already exists",
+			body:       `{"category_id":1,"name":"the_keyword"}`,
+			want:       `{"message":"keyword already exists"}`,
+			statusCode: http.StatusConflict,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createKeywordParams: createKeywordParams{
+						{
+							name:       "the_keyword",
+							categoryID: 1,
+							err:        state.ErrKeywordExists,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "category not found",
+			body:       `{"category_id":1,"name":"the_keyword"}`,
+			want:       `{"message":"category not found"}`,
+			statusCode: http.StatusNotFound,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createKeywordParams: createKeywordParams{
+						{
+							name:       "the_keyword",
+							categoryID: 1,
+							err:        state.ErrKeywordCategoryNotFound,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "runtime error",
+			body:       `{"category_id":1,"name":"the_keyword"}`,
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createKeywordParams: createKeywordParams{
+						{
+							name:       "the_keyword",
+							categoryID: 1,
+							err:        errors.New("error creating keyword"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "bad input",
+			body:       `{"category_id":1,"name":"the_keyword"`,
+			want:       `{"message":"malformed input"}`,
+			statusCode: http.StatusBadRequest,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createKeywordParams: createKeywordParams{},
+				},
+			},
+		},
+		{
+			name:       "successful creation",
+			body:       `{"category_id":1,"name":"the_keyword"}`,
+			want:       `{"id":1,"name":"the_keyword"}`,
+			statusCode: http.StatusCreated,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					createKeywordParams: createKeywordParams{
+						{
+							name:       "the_keyword",
+							categoryID: 1,
+							result: state.Keyword{
+								ID:   1,
+								Name: "the_keyword",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodPost, "/directory/keyword", strings.NewReader(tc.body))
+			responseRecorder := httptest.NewRecorder()
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.createKeywordParams {
+				directoryManager.EXPECT().
+					CreateKeyword(params.name, params.categoryID).
+					Return(params.result, params.err)
+			}
+
+			postDirectoryKeywordHandler(responseRecorder, request, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestDirectoryKeywordHandler_DELETE(t *testing.T) {
+	tt := []struct {
+		name       string
+		categoryID int
+		want       string
+		statusCode int
+		mockParams mockParams
+	}{
+		{
+			name:       "keyword not found",
+			categoryID: 1,
+			want:       `{"message":"keyword not found"}`,
+			statusCode: http.StatusNotFound,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteKeywordParams: deleteKeywordParams{
+						{
+							id:  1,
+							err: state.ErrKeywordNotFound,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "keyword in use by user",
+			categoryID: 1,
+			want:       `{"message":"can't delete because category in use by a user"}`,
+			statusCode: http.StatusConflict,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteKeywordParams: deleteKeywordParams{
+						{
+							id:  1,
+							err: state.ErrKeywordInUse,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "runtime error",
+			categoryID: 1,
+			want:       `{"message":"internal server error"}`,
+			statusCode: http.StatusInternalServerError,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteKeywordParams: deleteKeywordParams{
+						{
+							id:  1,
+							err: errors.New("error deleting keyword"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "successful deletion",
+			categoryID: 1,
+			want:       ``,
+			statusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteKeywordParams: deleteKeywordParams{
+						{
+							id: 1,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "invalid keyword ID",
+			categoryID: -1,
+			want:       `{"message":"invalid keyword ID"}`,
+			statusCode: http.StatusBadRequest,
+			mockParams: mockParams{
+				directoryManagerParams: directoryManagerParams{
+					deleteKeywordParams: deleteKeywordParams{},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/directory/keyword/%d", tc.categoryID), nil)
+			request.SetPathValue("id", fmt.Sprintf("%d", tc.categoryID))
+			responseRecorder := httptest.NewRecorder()
+
+			directoryManager := newMockDirectoryManager(t)
+			for _, params := range tc.mockParams.deleteKeywordParams {
+				directoryManager.EXPECT().
+					DeleteKeyword(params.id).
+					Return(params.err)
+			}
+
+			deleteDirectoryKeywordHandler(responseRecorder, request, directoryManager, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}

+ 355 - 0
server/http/mock_directory_manager_test.go

@@ -0,0 +1,355 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package http
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockDirectoryManager is an autogenerated mock type for the DirectoryManager type
+type mockDirectoryManager struct {
+	mock.Mock
+}
+
+type mockDirectoryManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockDirectoryManager) EXPECT() *mockDirectoryManager_Expecter {
+	return &mockDirectoryManager_Expecter{mock: &_m.Mock}
+}
+
+// Categories provides a mock function with given fields:
+func (_m *mockDirectoryManager) Categories() ([]state.Category, error) {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for Categories")
+	}
+
+	var r0 []state.Category
+	var r1 error
+	if rf, ok := ret.Get(0).(func() ([]state.Category, error)); ok {
+		return rf()
+	}
+	if rf, ok := ret.Get(0).(func() []state.Category); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.Category)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockDirectoryManager_Categories_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Categories'
+type mockDirectoryManager_Categories_Call struct {
+	*mock.Call
+}
+
+// Categories is a helper method to define mock.On call
+func (_e *mockDirectoryManager_Expecter) Categories() *mockDirectoryManager_Categories_Call {
+	return &mockDirectoryManager_Categories_Call{Call: _e.mock.On("Categories")}
+}
+
+func (_c *mockDirectoryManager_Categories_Call) Run(run func()) *mockDirectoryManager_Categories_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_Categories_Call) Return(_a0 []state.Category, _a1 error) *mockDirectoryManager_Categories_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockDirectoryManager_Categories_Call) RunAndReturn(run func() ([]state.Category, error)) *mockDirectoryManager_Categories_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// CreateCategory provides a mock function with given fields: name
+func (_m *mockDirectoryManager) CreateCategory(name string) (state.Category, error) {
+	ret := _m.Called(name)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CreateCategory")
+	}
+
+	var r0 state.Category
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string) (state.Category, error)); ok {
+		return rf(name)
+	}
+	if rf, ok := ret.Get(0).(func(string) state.Category); ok {
+		r0 = rf(name)
+	} else {
+		r0 = ret.Get(0).(state.Category)
+	}
+
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(name)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockDirectoryManager_CreateCategory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateCategory'
+type mockDirectoryManager_CreateCategory_Call struct {
+	*mock.Call
+}
+
+// CreateCategory is a helper method to define mock.On call
+//   - name string
+func (_e *mockDirectoryManager_Expecter) CreateCategory(name interface{}) *mockDirectoryManager_CreateCategory_Call {
+	return &mockDirectoryManager_CreateCategory_Call{Call: _e.mock.On("CreateCategory", name)}
+}
+
+func (_c *mockDirectoryManager_CreateCategory_Call) Run(run func(name string)) *mockDirectoryManager_CreateCategory_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_CreateCategory_Call) Return(_a0 state.Category, _a1 error) *mockDirectoryManager_CreateCategory_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockDirectoryManager_CreateCategory_Call) RunAndReturn(run func(string) (state.Category, error)) *mockDirectoryManager_CreateCategory_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// CreateKeyword provides a mock function with given fields: name, categoryID
+func (_m *mockDirectoryManager) CreateKeyword(name string, categoryID uint8) (state.Keyword, error) {
+	ret := _m.Called(name, categoryID)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CreateKeyword")
+	}
+
+	var r0 state.Keyword
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string, uint8) (state.Keyword, error)); ok {
+		return rf(name, categoryID)
+	}
+	if rf, ok := ret.Get(0).(func(string, uint8) state.Keyword); ok {
+		r0 = rf(name, categoryID)
+	} else {
+		r0 = ret.Get(0).(state.Keyword)
+	}
+
+	if rf, ok := ret.Get(1).(func(string, uint8) error); ok {
+		r1 = rf(name, categoryID)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockDirectoryManager_CreateKeyword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateKeyword'
+type mockDirectoryManager_CreateKeyword_Call struct {
+	*mock.Call
+}
+
+// CreateKeyword is a helper method to define mock.On call
+//   - name string
+//   - categoryID uint8
+func (_e *mockDirectoryManager_Expecter) CreateKeyword(name interface{}, categoryID interface{}) *mockDirectoryManager_CreateKeyword_Call {
+	return &mockDirectoryManager_CreateKeyword_Call{Call: _e.mock.On("CreateKeyword", name, categoryID)}
+}
+
+func (_c *mockDirectoryManager_CreateKeyword_Call) Run(run func(name string, categoryID uint8)) *mockDirectoryManager_CreateKeyword_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string), args[1].(uint8))
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_CreateKeyword_Call) Return(_a0 state.Keyword, _a1 error) *mockDirectoryManager_CreateKeyword_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockDirectoryManager_CreateKeyword_Call) RunAndReturn(run func(string, uint8) (state.Keyword, error)) *mockDirectoryManager_CreateKeyword_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DeleteCategory provides a mock function with given fields: categoryID
+func (_m *mockDirectoryManager) DeleteCategory(categoryID uint8) error {
+	ret := _m.Called(categoryID)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteCategory")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(uint8) error); ok {
+		r0 = rf(categoryID)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockDirectoryManager_DeleteCategory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteCategory'
+type mockDirectoryManager_DeleteCategory_Call struct {
+	*mock.Call
+}
+
+// DeleteCategory is a helper method to define mock.On call
+//   - categoryID uint8
+func (_e *mockDirectoryManager_Expecter) DeleteCategory(categoryID interface{}) *mockDirectoryManager_DeleteCategory_Call {
+	return &mockDirectoryManager_DeleteCategory_Call{Call: _e.mock.On("DeleteCategory", categoryID)}
+}
+
+func (_c *mockDirectoryManager_DeleteCategory_Call) Run(run func(categoryID uint8)) *mockDirectoryManager_DeleteCategory_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint8))
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_DeleteCategory_Call) Return(_a0 error) *mockDirectoryManager_DeleteCategory_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockDirectoryManager_DeleteCategory_Call) RunAndReturn(run func(uint8) error) *mockDirectoryManager_DeleteCategory_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DeleteKeyword provides a mock function with given fields: id
+func (_m *mockDirectoryManager) DeleteKeyword(id uint8) error {
+	ret := _m.Called(id)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteKeyword")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(uint8) error); ok {
+		r0 = rf(id)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockDirectoryManager_DeleteKeyword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteKeyword'
+type mockDirectoryManager_DeleteKeyword_Call struct {
+	*mock.Call
+}
+
+// DeleteKeyword is a helper method to define mock.On call
+//   - id uint8
+func (_e *mockDirectoryManager_Expecter) DeleteKeyword(id interface{}) *mockDirectoryManager_DeleteKeyword_Call {
+	return &mockDirectoryManager_DeleteKeyword_Call{Call: _e.mock.On("DeleteKeyword", id)}
+}
+
+func (_c *mockDirectoryManager_DeleteKeyword_Call) Run(run func(id uint8)) *mockDirectoryManager_DeleteKeyword_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint8))
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_DeleteKeyword_Call) Return(_a0 error) *mockDirectoryManager_DeleteKeyword_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockDirectoryManager_DeleteKeyword_Call) RunAndReturn(run func(uint8) error) *mockDirectoryManager_DeleteKeyword_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// KeywordsByCategory provides a mock function with given fields: categoryID
+func (_m *mockDirectoryManager) KeywordsByCategory(categoryID uint8) ([]state.Keyword, error) {
+	ret := _m.Called(categoryID)
+
+	if len(ret) == 0 {
+		panic("no return value specified for KeywordsByCategory")
+	}
+
+	var r0 []state.Keyword
+	var r1 error
+	if rf, ok := ret.Get(0).(func(uint8) ([]state.Keyword, error)); ok {
+		return rf(categoryID)
+	}
+	if rf, ok := ret.Get(0).(func(uint8) []state.Keyword); ok {
+		r0 = rf(categoryID)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.Keyword)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(uint8) error); ok {
+		r1 = rf(categoryID)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockDirectoryManager_KeywordsByCategory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KeywordsByCategory'
+type mockDirectoryManager_KeywordsByCategory_Call struct {
+	*mock.Call
+}
+
+// KeywordsByCategory is a helper method to define mock.On call
+//   - categoryID uint8
+func (_e *mockDirectoryManager_Expecter) KeywordsByCategory(categoryID interface{}) *mockDirectoryManager_KeywordsByCategory_Call {
+	return &mockDirectoryManager_KeywordsByCategory_Call{Call: _e.mock.On("KeywordsByCategory", categoryID)}
+}
+
+func (_c *mockDirectoryManager_KeywordsByCategory_Call) Run(run func(categoryID uint8)) *mockDirectoryManager_KeywordsByCategory_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint8))
+	})
+	return _c
+}
+
+func (_c *mockDirectoryManager_KeywordsByCategory_Call) Return(_a0 []state.Keyword, _a1 error) *mockDirectoryManager_KeywordsByCategory_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockDirectoryManager_KeywordsByCategory_Call) RunAndReturn(run func(uint8) ([]state.Keyword, error)) *mockDirectoryManager_KeywordsByCategory_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockDirectoryManager creates a new instance of mockDirectoryManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockDirectoryManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockDirectoryManager {
+	mock := &mockDirectoryManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 56 - 0
server/http/test_helpers.go

@@ -14,6 +14,7 @@ type mockParams struct {
 	chatRoomCreatorParams
 	chatRoomRetrieverParams
 	chatSessionRetrieverParams
+	directoryManagerParams
 	feedBagRetrieverParams
 	messageRelayerParams
 	profileRetrieverParams
@@ -107,6 +108,61 @@ type chatSessionRetrieverAllSessionsParams []struct {
 	result []*state.Session
 }
 
+type directoryManagerParams struct {
+	categoriesParams
+	createCategoryParams
+	createKeywordParams
+	deleteCategoryParams
+	deleteKeywordParams
+	keywordsByCategoryParams
+}
+
+// categoriesParams is the list of parameters passed at the mock
+// DirectoryManager.Categories call site
+type categoriesParams []struct {
+	result []state.Category
+	err    error
+}
+
+// createCategoryParams is the list of parameters passed at the mock
+// DirectoryManager.CreateCategory call site
+type createCategoryParams []struct {
+	name   string
+	result state.Category
+	err    error
+}
+
+// createKeywordParams is the list of parameters passed at the mock
+// DirectoryManager.CreateKeyword call site
+type createKeywordParams []struct {
+	name       string
+	categoryID uint8
+	result     state.Keyword
+	err        error
+}
+
+// deleteCategoryParams is the list of parameters passed at the mock
+// DirectoryManager.DeleteCategory call site
+type deleteCategoryParams []struct {
+	categoryID uint8
+	err        error
+}
+
+// deleteKeywordParams is the list of parameters passed at the mock
+// DirectoryManager.DeleteKeyword call site
+type deleteKeywordParams []struct {
+	id  uint8
+	err error
+}
+
+// keywordsByCategoryParams is the list of parameters passed at the mock
+// DirectoryManager.KeywordsByCategory call site
+type keywordsByCategoryParams []struct {
+	categoryID uint8
+	result     []state.Keyword
+	err        error
+}
+
 // feedBagRetrieverParams is a helper struct that contains mock parameters for
 // FeedBagRetriever methods
 type feedBagRetrieverParams struct {

+ 36 - 4
server/http/types.go

@@ -52,6 +52,19 @@ type FeedBagRetriever interface {
 	BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error)
 }
 
+type ProfileRetriever interface {
+	Profile(screenName state.IdentScreenName) (string, error)
+}
+
+type DirectoryManager interface {
+	Categories() ([]state.Category, error)
+	CreateCategory(name string) (state.Category, error)
+	CreateKeyword(name string, categoryID uint8) (state.Keyword, error)
+	DeleteCategory(categoryID uint8) error
+	DeleteKeyword(id uint8) error
+	KeywordsByCategory(categoryID uint8) ([]state.Keyword, error)
+}
+
 type userWithPassword struct {
 	ScreenName string `json:"screen_name"`
 	Password   string `json:"password,omitempty"`
@@ -92,10 +105,6 @@ type sessionHandle struct {
 	IsICQ         bool    `json:"is_icq"`
 }
 
-type ProfileRetriever interface {
-	Profile(screenName state.IdentScreenName) (string, error)
-}
-
 type chatRoomCreate struct {
 	Name string `json:"name"`
 }
@@ -113,3 +122,26 @@ type instantMessage struct {
 	To   string `json:"to"`
 	Text string `json:"text"`
 }
+
+type directoryKeyword struct {
+	ID   uint8  `json:"id"`
+	Name string `json:"name"`
+}
+
+type directoryCategory struct {
+	ID   uint8  `json:"id"`
+	Name string `json:"name"`
+}
+
+type directoryCategoryCreate struct {
+	Name string `json:"name"`
+}
+
+type directoryKeywordCreate struct {
+	CategoryID uint8  `json:"category_id"`
+	Name       string `json:"name"`
+}
+
+type messageBody struct {
+	Message string `json:"message"`
+}

+ 6 - 6
server/oscar/handler/icq.go

@@ -17,10 +17,10 @@ import (
 
 type ICQService interface {
 	DeleteMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
-	FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error
-	FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error
+	FindByICQName(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error
+	FindByICQEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error
 	FindByEmail3(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error
-	FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error
+	FindByICQInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error
 	FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
 	FindByUIN2(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2, seq uint16) error
 	FindByWhitePages2(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2, seq uint16) error
@@ -142,7 +142,7 @@ func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame w
 			if err := wire.UnmarshalLE(&req, buf); err != nil {
 				return err
 			}
-			if err := rt.ICQService.FindByEmail(ctx, sess, req, icqMD.Seq); err != nil {
+			if err := rt.ICQService.FindByICQEmail(ctx, sess, req, icqMD.Seq); err != nil {
 				return err
 			}
 		case wire.ICQDBQueryMetaReqSearchByEmail3:
@@ -158,7 +158,7 @@ func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame w
 			if err := wire.UnmarshalLE(&req, buf); err != nil {
 				return err
 			}
-			if err := rt.ICQService.FindByDetails(ctx, sess, req, icqMD.Seq); err != nil {
+			if err := rt.ICQService.FindByICQName(ctx, sess, req, icqMD.Seq); err != nil {
 				return err
 			}
 		case wire.ICQDBQueryMetaReqSearchWhitePages:
@@ -166,7 +166,7 @@ func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame w
 			if err := wire.UnmarshalLE(&req, buf); err != nil {
 				return err
 			}
-			if err := rt.ICQService.FindByInterests(ctx, sess, req, icqMD.Seq); err != nil {
+			if err := rt.ICQService.FindByICQInterests(ctx, sess, req, icqMD.Seq); err != nil {
 				return err
 			}
 		case wire.ICQDBQueryMetaReqSearchWhitePages2:

+ 3 - 3
server/oscar/handler/icq_test.go

@@ -901,7 +901,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 					Return(tt.allMockParams.findByUIN2.wantErr)
 			case tt.allMockParams.findByEmail != nil:
 				icqService.EXPECT().
-					FindByEmail(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail.req, tt.reqParams.seq).
+					FindByICQEmail(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail.req, tt.reqParams.seq).
 					Return(tt.allMockParams.findByEmail.wantErr)
 			case tt.allMockParams.findByEmail3 != nil:
 				icqService.EXPECT().
@@ -909,11 +909,11 @@ func TestICQHandler_DBQuery(t *testing.T) {
 					Return(tt.allMockParams.findByEmail3.wantErr)
 			case tt.allMockParams.findByDetails != nil:
 				icqService.EXPECT().
-					FindByDetails(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByDetails.req, tt.reqParams.seq).
+					FindByICQName(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByDetails.req, tt.reqParams.seq).
 					Return(tt.allMockParams.findByDetails.wantErr)
 			case tt.allMockParams.findByInterests != nil:
 				icqService.EXPECT().
-					FindByInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByInterests.req, tt.reqParams.seq).
+					FindByICQInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByInterests.req, tt.reqParams.seq).
 					Return(tt.allMockParams.findByInterests.wantErr)
 			case tt.allMockParams.findByWhitePages2 != nil:
 				icqService.EXPECT().

+ 12 - 6
server/oscar/handler/locate.go

@@ -14,9 +14,9 @@ import (
 
 type LocateService interface {
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	SetDirInfo(ctx context.Context, frame wire.SNACFrame) wire.SNACMessage
+	SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)
 	SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
-	SetKeywordInfo(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error)
 	UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
 }
 
@@ -49,12 +49,15 @@ func (h LocateHandler) SetInfo(ctx context.Context, sess *state.Session, inFrame
 	return h.LocateService.SetInfo(ctx, sess, inBody)
 }
 
-func (h LocateHandler) SetDirInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+func (h LocateHandler) SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
 	inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 	}
-	outSNAC := h.LocateService.SetDirInfo(ctx, inFrame)
+	outSNAC, err := h.LocateService.SetDirInfo(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
 	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
@@ -65,12 +68,15 @@ func (h LocateHandler) GetDirInfo(ctx context.Context, _ *state.Session, inFrame
 	return wire.UnmarshalBE(&inBody, r)
 }
 
-func (h LocateHandler) SetKeywordInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+func (h LocateHandler) SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
 	inBody := wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 	}
-	outSNAC := h.LocateService.SetKeywordInfo(ctx, inFrame)
+	outSNAC, err := h.LocateService.SetKeywordInfo(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
 	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }

+ 4 - 4
server/oscar/handler/locate_test.go

@@ -101,8 +101,8 @@ func TestLocateHandler_SetDirInfo(t *testing.T) {
 
 	svc := newMockLocateService(t)
 	svc.EXPECT().
-		SetDirInfo(mock.Anything, input.Frame).
-		Return(output)
+		SetDirInfo(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
 
 	h := NewLocateHandler(svc, slog.Default())
 
@@ -178,8 +178,8 @@ func TestLocateHandler_SetKeywordInfo(t *testing.T) {
 
 	svc := newMockLocateService(t)
 	svc.EXPECT().
-		SetKeywordInfo(mock.Anything, input.Frame).
-		Return(output)
+		SetKeywordInfo(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
 
 	h := NewLocateHandler(svc, slog.Default())
 

+ 53 - 53
server/oscar/handler/mock_icq_service_test.go

@@ -72,16 +72,16 @@ func (_c *mockICQService_DeleteMsgReq_Call) RunAndReturn(run func(context.Contex
 	return _c
 }
 
-// FindByDetails provides a mock function with given fields: ctx, sess, req, seq
-func (_m *mockICQService) FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
+// FindByEmail3 provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByEmail3(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error {
 	ret := _m.Called(ctx, sess, req, seq)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByDetails")
+		panic("no return value specified for FindByEmail3")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, uint16) error); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, uint16) error); ok {
 		r0 = rf(ctx, sess, req, seq)
 	} else {
 		r0 = ret.Error(0)
@@ -90,43 +90,43 @@ func (_m *mockICQService) FindByDetails(ctx context.Context, sess *state.Session
 	return r0
 }
 
-// mockICQService_FindByDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByDetails'
-type mockICQService_FindByDetails_Call struct {
+// mockICQService_FindByEmail3_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail3'
+type mockICQService_FindByEmail3_Call struct {
 	*mock.Call
 }
 
-// FindByDetails is a helper method to define mock.On call
+// FindByEmail3 is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
-//   - req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails
+//   - req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3
 //   - seq uint16
-func (_e *mockICQService_Expecter) FindByDetails(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByDetails_Call {
-	return &mockICQService_FindByDetails_Call{Call: _e.mock.On("FindByDetails", ctx, sess, req, seq)}
+func (_e *mockICQService_Expecter) FindByEmail3(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByEmail3_Call {
+	return &mockICQService_FindByEmail3_Call{Call: _e.mock.On("FindByEmail3", ctx, sess, req, seq)}
 }
 
-func (_c *mockICQService_FindByDetails_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16)) *mockICQService_FindByDetails_Call {
+func (_c *mockICQService_FindByEmail3_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16)) *mockICQService_FindByEmail3_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails), args[3].(uint16))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3), args[3].(uint16))
 	})
 	return _c
 }
 
-func (_c *mockICQService_FindByDetails_Call) Return(_a0 error) *mockICQService_FindByDetails_Call {
+func (_c *mockICQService_FindByEmail3_Call) Return(_a0 error) *mockICQService_FindByEmail3_Call {
 	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockICQService_FindByDetails_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, uint16) error) *mockICQService_FindByDetails_Call {
+func (_c *mockICQService_FindByEmail3_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, uint16) error) *mockICQService_FindByEmail3_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FindByEmail provides a mock function with given fields: ctx, sess, req, seq
-func (_m *mockICQService) FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
+// FindByICQEmail provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByICQEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
 	ret := _m.Called(ctx, sess, req, seq)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByEmail")
+		panic("no return value specified for FindByICQEmail")
 	}
 
 	var r0 error
@@ -139,47 +139,47 @@ func (_m *mockICQService) FindByEmail(ctx context.Context, sess *state.Session,
 	return r0
 }
 
-// mockICQService_FindByEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail'
-type mockICQService_FindByEmail_Call struct {
+// mockICQService_FindByICQEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQEmail'
+type mockICQService_FindByICQEmail_Call struct {
 	*mock.Call
 }
 
-// FindByEmail is a helper method to define mock.On call
+// FindByICQEmail is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
 //   - req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail
 //   - seq uint16
-func (_e *mockICQService_Expecter) FindByEmail(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByEmail_Call {
-	return &mockICQService_FindByEmail_Call{Call: _e.mock.On("FindByEmail", ctx, sess, req, seq)}
+func (_e *mockICQService_Expecter) FindByICQEmail(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByICQEmail_Call {
+	return &mockICQService_FindByICQEmail_Call{Call: _e.mock.On("FindByICQEmail", ctx, sess, req, seq)}
 }
 
-func (_c *mockICQService_FindByEmail_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16)) *mockICQService_FindByEmail_Call {
+func (_c *mockICQService_FindByICQEmail_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16)) *mockICQService_FindByICQEmail_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail), args[3].(uint16))
 	})
 	return _c
 }
 
-func (_c *mockICQService_FindByEmail_Call) Return(_a0 error) *mockICQService_FindByEmail_Call {
+func (_c *mockICQService_FindByICQEmail_Call) Return(_a0 error) *mockICQService_FindByICQEmail_Call {
 	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockICQService_FindByEmail_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, uint16) error) *mockICQService_FindByEmail_Call {
+func (_c *mockICQService_FindByICQEmail_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, uint16) error) *mockICQService_FindByICQEmail_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FindByEmail3 provides a mock function with given fields: ctx, sess, req, seq
-func (_m *mockICQService) FindByEmail3(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error {
+// FindByICQInterests provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByICQInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
 	ret := _m.Called(ctx, sess, req, seq)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByEmail3")
+		panic("no return value specified for FindByICQInterests")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, uint16) error); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, uint16) error); ok {
 		r0 = rf(ctx, sess, req, seq)
 	} else {
 		r0 = ret.Error(0)
@@ -188,47 +188,47 @@ func (_m *mockICQService) FindByEmail3(ctx context.Context, sess *state.Session,
 	return r0
 }
 
-// mockICQService_FindByEmail3_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail3'
-type mockICQService_FindByEmail3_Call struct {
+// mockICQService_FindByICQInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQInterests'
+type mockICQService_FindByICQInterests_Call struct {
 	*mock.Call
 }
 
-// FindByEmail3 is a helper method to define mock.On call
+// FindByICQInterests is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
-//   - req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3
+//   - req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages
 //   - seq uint16
-func (_e *mockICQService_Expecter) FindByEmail3(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByEmail3_Call {
-	return &mockICQService_FindByEmail3_Call{Call: _e.mock.On("FindByEmail3", ctx, sess, req, seq)}
+func (_e *mockICQService_Expecter) FindByICQInterests(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByICQInterests_Call {
+	return &mockICQService_FindByICQInterests_Call{Call: _e.mock.On("FindByICQInterests", ctx, sess, req, seq)}
 }
 
-func (_c *mockICQService_FindByEmail3_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16)) *mockICQService_FindByEmail3_Call {
+func (_c *mockICQService_FindByICQInterests_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16)) *mockICQService_FindByICQInterests_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3), args[3].(uint16))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages), args[3].(uint16))
 	})
 	return _c
 }
 
-func (_c *mockICQService_FindByEmail3_Call) Return(_a0 error) *mockICQService_FindByEmail3_Call {
+func (_c *mockICQService_FindByICQInterests_Call) Return(_a0 error) *mockICQService_FindByICQInterests_Call {
 	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockICQService_FindByEmail3_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, uint16) error) *mockICQService_FindByEmail3_Call {
+func (_c *mockICQService_FindByICQInterests_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, uint16) error) *mockICQService_FindByICQInterests_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FindByInterests provides a mock function with given fields: ctx, sess, req, seq
-func (_m *mockICQService) FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
+// FindByICQName provides a mock function with given fields: ctx, sess, req, seq
+func (_m *mockICQService) FindByICQName(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
 	ret := _m.Called(ctx, sess, req, seq)
 
 	if len(ret) == 0 {
-		panic("no return value specified for FindByInterests")
+		panic("no return value specified for FindByICQName")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, uint16) error); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, uint16) error); ok {
 		r0 = rf(ctx, sess, req, seq)
 	} else {
 		r0 = ret.Error(0)
@@ -237,33 +237,33 @@ func (_m *mockICQService) FindByInterests(ctx context.Context, sess *state.Sessi
 	return r0
 }
 
-// mockICQService_FindByInterests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByInterests'
-type mockICQService_FindByInterests_Call struct {
+// mockICQService_FindByICQName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByICQName'
+type mockICQService_FindByICQName_Call struct {
 	*mock.Call
 }
 
-// FindByInterests is a helper method to define mock.On call
+// FindByICQName is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
-//   - req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages
+//   - req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails
 //   - seq uint16
-func (_e *mockICQService_Expecter) FindByInterests(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByInterests_Call {
-	return &mockICQService_FindByInterests_Call{Call: _e.mock.On("FindByInterests", ctx, sess, req, seq)}
+func (_e *mockICQService_Expecter) FindByICQName(ctx interface{}, sess interface{}, req interface{}, seq interface{}) *mockICQService_FindByICQName_Call {
+	return &mockICQService_FindByICQName_Call{Call: _e.mock.On("FindByICQName", ctx, sess, req, seq)}
 }
 
-func (_c *mockICQService_FindByInterests_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16)) *mockICQService_FindByInterests_Call {
+func (_c *mockICQService_FindByICQName_Call) Run(run func(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16)) *mockICQService_FindByICQName_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages), args[3].(uint16))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails), args[3].(uint16))
 	})
 	return _c
 }
 
-func (_c *mockICQService_FindByInterests_Call) Return(_a0 error) *mockICQService_FindByInterests_Call {
+func (_c *mockICQService_FindByICQName_Call) Return(_a0 error) *mockICQService_FindByICQName_Call {
 	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockICQService_FindByInterests_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, uint16) error) *mockICQService_FindByInterests_Call {
+func (_c *mockICQService_FindByICQName_Call) RunAndReturn(run func(context.Context, *state.Session, wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, uint16) error) *mockICQService_FindByICQName_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 51 - 27
server/oscar/handler/mock_locate_test.go

@@ -71,22 +71,32 @@ func (_c *mockLocateService_RightsQuery_Call) RunAndReturn(run func(context.Cont
 	return _c
 }
 
-// SetDirInfo provides a mock function with given fields: ctx, frame
-func (_m *mockLocateService) SetDirInfo(ctx context.Context, frame wire.SNACFrame) wire.SNACMessage {
-	ret := _m.Called(ctx, frame)
+// SetDirInfo provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockLocateService) SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
 
 	if len(ret) == 0 {
 		panic("no return value specified for SetDirInfo")
 	}
 
 	var r0 wire.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
-		r0 = rf(ctx, frame)
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	return r0
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
 }
 
 // mockLocateService_SetDirInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDirInfo'
@@ -96,24 +106,26 @@ type mockLocateService_SetDirInfo_Call struct {
 
 // SetDirInfo is a helper method to define mock.On call
 //   - ctx context.Context
-//   - frame wire.SNACFrame
-func (_e *mockLocateService_Expecter) SetDirInfo(ctx interface{}, frame interface{}) *mockLocateService_SetDirInfo_Call {
-	return &mockLocateService_SetDirInfo_Call{Call: _e.mock.On("SetDirInfo", ctx, frame)}
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x09_LocateSetDirInfo
+func (_e *mockLocateService_Expecter) SetDirInfo(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_SetDirInfo_Call {
+	return &mockLocateService_SetDirInfo_Call{Call: _e.mock.On("SetDirInfo", ctx, sess, inFrame, inBody)}
 }
 
-func (_c *mockLocateService_SetDirInfo_Call) Run(run func(ctx context.Context, frame wire.SNACFrame)) *mockLocateService_SetDirInfo_Call {
+func (_c *mockLocateService_SetDirInfo_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo)) *mockLocateService_SetDirInfo_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x02_0x09_LocateSetDirInfo))
 	})
 	return _c
 }
 
-func (_c *mockLocateService_SetDirInfo_Call) Return(_a0 wire.SNACMessage) *mockLocateService_SetDirInfo_Call {
-	_c.Call.Return(_a0)
+func (_c *mockLocateService_SetDirInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockLocateService_SetDirInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockLocateService_SetDirInfo_Call {
+func (_c *mockLocateService_SetDirInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)) *mockLocateService_SetDirInfo_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -166,22 +178,32 @@ func (_c *mockLocateService_SetInfo_Call) RunAndReturn(run func(context.Context,
 	return _c
 }
 
-// SetKeywordInfo provides a mock function with given fields: ctx, inFrame
-func (_m *mockLocateService) SetKeywordInfo(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
-	ret := _m.Called(ctx, inFrame)
+// SetKeywordInfo provides a mock function with given fields: ctx, sess, inFrame, body
+func (_m *mockLocateService) SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, body)
 
 	if len(ret) == 0 {
 		panic("no return value specified for SetKeywordInfo")
 	}
 
 	var r0 wire.SNACMessage
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
-		r0 = rf(ctx, inFrame)
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, body)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, body)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	return r0
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) error); ok {
+		r1 = rf(ctx, sess, inFrame, body)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
 }
 
 // mockLocateService_SetKeywordInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetKeywordInfo'
@@ -191,24 +213,26 @@ type mockLocateService_SetKeywordInfo_Call struct {
 
 // SetKeywordInfo is a helper method to define mock.On call
 //   - ctx context.Context
+//   - sess *state.Session
 //   - inFrame wire.SNACFrame
-func (_e *mockLocateService_Expecter) SetKeywordInfo(ctx interface{}, inFrame interface{}) *mockLocateService_SetKeywordInfo_Call {
-	return &mockLocateService_SetKeywordInfo_Call{Call: _e.mock.On("SetKeywordInfo", ctx, inFrame)}
+//   - body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo
+func (_e *mockLocateService_Expecter) SetKeywordInfo(ctx interface{}, sess interface{}, inFrame interface{}, body interface{}) *mockLocateService_SetKeywordInfo_Call {
+	return &mockLocateService_SetKeywordInfo_Call{Call: _e.mock.On("SetKeywordInfo", ctx, sess, inFrame, body)}
 }
 
-func (_c *mockLocateService_SetKeywordInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockLocateService_SetKeywordInfo_Call {
+func (_c *mockLocateService_SetKeywordInfo_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo)) *mockLocateService_SetKeywordInfo_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x02_0x0F_LocateSetKeywordInfo))
 	})
 	return _c
 }
 
-func (_c *mockLocateService_SetKeywordInfo_Call) Return(_a0 wire.SNACMessage) *mockLocateService_SetKeywordInfo_Call {
-	_c.Call.Return(_a0)
+func (_c *mockLocateService_SetKeywordInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockLocateService_SetKeywordInfo_Call {
+	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockLocateService_SetKeywordInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockLocateService_SetKeywordInfo_Call {
+func (_c *mockLocateService_SetKeywordInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error)) *mockLocateService_SetKeywordInfo_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 152 - 0
server/oscar/handler/mock_odir_service_test.go

@@ -0,0 +1,152 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockODirService is an autogenerated mock type for the ODirService type
+type mockODirService struct {
+	mock.Mock
+}
+
+type mockODirService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockODirService) EXPECT() *mockODirService_Expecter {
+	return &mockODirService_Expecter{mock: &_m.Mock}
+}
+
+// InfoQuery provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockODirService) InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for InfoQuery")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) error); ok {
+		r1 = rf(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockODirService_InfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoQuery'
+type mockODirService_InfoQuery_Call struct {
+	*mock.Call
+}
+
+// InfoQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0F_0x02_InfoQuery
+func (_e *mockODirService_Expecter) InfoQuery(ctx interface{}, inFrame interface{}, inBody interface{}) *mockODirService_InfoQuery_Call {
+	return &mockODirService_InfoQuery_Call{Call: _e.mock.On("InfoQuery", ctx, inFrame, inBody)}
+}
+
+func (_c *mockODirService_InfoQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery)) *mockODirService_InfoQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x0F_0x02_InfoQuery))
+	})
+	return _c
+}
+
+func (_c *mockODirService_InfoQuery_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockODirService_InfoQuery_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockODirService_InfoQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)) *mockODirService_InfoQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// KeywordListQuery provides a mock function with given fields: _a0, _a1
+func (_m *mockODirService) KeywordListQuery(_a0 context.Context, _a1 wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _m.Called(_a0, _a1)
+
+	if len(ret) == 0 {
+		panic("no return value specified for KeywordListQuery")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return rf(_a0, _a1)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(_a0, _a1)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame) error); ok {
+		r1 = rf(_a0, _a1)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockODirService_KeywordListQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KeywordListQuery'
+type mockODirService_KeywordListQuery_Call struct {
+	*mock.Call
+}
+
+// KeywordListQuery is a helper method to define mock.On call
+//   - _a0 context.Context
+//   - _a1 wire.SNACFrame
+func (_e *mockODirService_Expecter) KeywordListQuery(_a0 interface{}, _a1 interface{}) *mockODirService_KeywordListQuery_Call {
+	return &mockODirService_KeywordListQuery_Call{Call: _e.mock.On("KeywordListQuery", _a0, _a1)}
+}
+
+func (_c *mockODirService_KeywordListQuery_Call) Run(run func(_a0 context.Context, _a1 wire.SNACFrame)) *mockODirService_KeywordListQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockODirService_KeywordListQuery_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockODirService_KeywordListQuery_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockODirService_KeywordListQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) (wire.SNACMessage, error)) *mockODirService_KeywordListQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockODirService creates a new instance of mockODirService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockODirService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockODirService {
+	mock := &mockODirService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 53 - 0
server/oscar/handler/odir.go

@@ -0,0 +1,53 @@
+package handler
+
+import (
+	"context"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type ODirService interface {
+	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
+	KeywordListQuery(context.Context, wire.SNACFrame) (wire.SNACMessage, error)
+}
+
+func NewODirHandler(logger *slog.Logger, oDirService ODirService) ODirHandler {
+	return ODirHandler{
+		ODirService: oDirService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type ODirHandler struct {
+	ODirService
+	middleware.RouteLogger
+}
+
+func (h ODirHandler) InfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := h.ODirService.InfoQuery(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (h ODirHandler) KeywordListQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC, err := h.ODirService.KeywordListQuery(ctx, inFrame)
+	if err != nil {
+		return err
+	}
+	h.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 96 - 0
server/oscar/handler/odir_test.go

@@ -0,0 +1,96 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestODirHandler_InfoQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ODir,
+			SubGroup:  wire.ODirInfoQuery,
+		},
+		Body: wire.SNAC_0x0F_0x02_InfoQuery{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(1, uint16(2)),
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ODir,
+			SubGroup:  wire.ODirInfoReply,
+		},
+		Body: wire.SNAC_0x0F_0x03_InfoReply{
+			Status: 5, // OK has results/not found
+		},
+	}
+
+	svc := newMockODirService(t)
+	svc.EXPECT().
+		InfoQuery(mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := NewODirHandler(slog.Default(), svc)
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.InfoQuery(nil, nil, input.Frame, buf, ss))
+}
+
+func TestODirHandler_KeywordListQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ODir,
+			SubGroup:  wire.ODirKeywordListQuery,
+		},
+		Body: wire.SNAC_0x0F_0x02_InfoQuery{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(1, uint16(2)),
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ODir,
+			SubGroup:  wire.ODirKeywordListReply,
+		},
+		Body: wire.SNAC_0x0F_0x04_KeywordListReply{
+			Status: 0x01,
+		},
+	}
+
+	svc := newMockODirService(t)
+	svc.EXPECT().
+		KeywordListQuery(mock.Anything, input.Frame).
+		Return(output, nil)
+
+	h := NewODirHandler(slog.Default(), svc)
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.KeywordListQuery(nil, nil, input.Frame, buf, ss))
+}

+ 19 - 4
server/oscar/handler/routes.go

@@ -20,6 +20,7 @@ type Handlers struct {
 	ICBMHandler
 	ICQHandler
 	LocateHandler
+	ODirHandler
 	OServiceHandler
 	PermitDenyHandler
 }
@@ -72,10 +73,6 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.Locate, wire.LocateUserInfoQuery, h.LocateHandler.UserInfoQuery)
 	router.Register(wire.Locate, wire.LocateUserInfoQuery2, h.LocateHandler.UserInfoQuery2)
 
-	router.Register(wire.PermitDeny, wire.PermitDenyRightsQuery, h.PermitDenyHandler.RightsQuery)
-	router.Register(wire.PermitDeny, wire.PermitDenyAddPermListEntries, h.PermitDenyHandler.AddPermListEntries)
-	router.Register(wire.PermitDeny, wire.PermitDenySetGroupPermitMask, h.PermitDenyHandler.SetGroupPermitMask)
-
 	router.Register(wire.OService, wire.OServiceClientOnline, h.OServiceHandler.ClientOnline)
 	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
 	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceHandler.IdleNotification)
@@ -87,6 +84,10 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.OService, wire.OServiceUserInfoQuery, h.OServiceHandler.UserInfoQuery)
 	router.Register(wire.OService, wire.OServiceSetPrivacyFlags, h.OServiceHandler.SetPrivacyFlags)
 
+	router.Register(wire.PermitDeny, wire.PermitDenyRightsQuery, h.PermitDenyHandler.RightsQuery)
+	router.Register(wire.PermitDeny, wire.PermitDenyAddPermListEntries, h.PermitDenyHandler.AddPermListEntries)
+	router.Register(wire.PermitDeny, wire.PermitDenySetGroupPermitMask, h.PermitDenyHandler.SetGroupPermitMask)
+
 	return router
 }
 
@@ -175,3 +176,17 @@ func NewAdminRouter(h Handlers) oscar.Router {
 
 	return router
 }
+
+func NewODirRouter(h Handlers) oscar.Router {
+	router := oscar.NewRouter()
+
+	router.Register(wire.ODir, wire.ODirInfoQuery, h.ODirHandler.InfoQuery)
+	router.Register(wire.ODir, wire.ODirKeywordListQuery, h.ODirHandler.KeywordListQuery)
+
+	router.Register(wire.OService, wire.OServiceClientOnline, h.ClientOnline)
+	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
+	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
+	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
+
+	return router
+}

+ 208 - 0
state/migrations/0009_aim_directory.down.sql

@@ -0,0 +1,208 @@
+ALTER TABLE users
+    RENAME TO users_new;
+
+CREATE TABLE users
+(
+    identScreenName                  VARCHAR(16) PRIMARY KEY,
+    displayScreenName                TEXT,
+    authKey                          TEXT,
+    strongMD5Pass                    TEXT,
+    weakMD5Pass                      TEXT,
+    confirmStatus                    BOOL                  DEFAULT FALSE,
+    emailAddress                     VARCHAR(320) NOT NULL DEFAULT '',
+    regStatus                        INT          NOT NULL DEFAULT 3,
+    isICQ                            BOOLEAN      NOT NULL DEFAULT false,
+    icq_affiliations_currentCode1    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentCode2    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentCode3    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentKeyword1 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_currentKeyword2 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_currentKeyword3 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastCode1       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastCode2       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastCode3       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastKeyword1    TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastKeyword2    TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastKeyword3    TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_address            TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_cellPhone          TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_city               TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_countryCode        INTEGER      NOT NULL DEFAULT 0,
+    icq_basicInfo_emailAddress       TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_fax                TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_firstName          TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_gmtOffset          INTEGER      NOT NULL DEFAULT 0,
+    icq_basicInfo_lastName           TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_nickName           TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_phone              TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_publishEmail       BOOLEAN      NOT NULL DEFAULT false,
+    icq_basicInfo_state              TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_zipCode            TEXT         NOT NULL DEFAULT '',
+    icq_interests_code1              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code2              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code3              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code4              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_keyword1           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword2           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword3           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword4           TEXT         NOT NULL DEFAULT '',
+    icq_moreInfo_birthDay            INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_birthMonth          INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_birthYear           INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_gender              INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_homePageAddr        TEXT         NOT NULL DEFAULT '',
+    icq_moreInfo_lang1               INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_lang2               INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_lang3               INTEGER      NOT NULL DEFAULT 0,
+    icq_notes                        TEXT         NOT NULL DEFAULT '',
+    icq_permissions_authRequired     BOOLEAN      NOT NULL DEFAULT false,
+    icq_workInfo_address             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_city                TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_company             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_countryCode         INTEGER      NOT NULL DEFAULT 0,
+    icq_workInfo_department          TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_fax                 TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_occupationCode      INTEGER      NOT NULL DEFAULT 0,
+    icq_workInfo_phone               TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_position            TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_state               TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_webPage             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_zipCode             TEXT         NOT NULL DEFAULT ''
+);
+
+INSERT INTO users (identScreenName,
+                   displayScreenName,
+                   authKey,
+                   strongMD5Pass,
+                   weakMD5Pass,
+                   confirmStatus,
+                   emailAddress,
+                   regStatus,
+                   isICQ,
+                   icq_affiliations_currentCode1,
+                   icq_affiliations_currentCode2,
+                   icq_affiliations_currentCode3,
+                   icq_affiliations_currentKeyword1,
+                   icq_affiliations_currentKeyword2,
+                   icq_affiliations_currentKeyword3,
+                   icq_affiliations_pastCode1,
+                   icq_affiliations_pastCode2,
+                   icq_affiliations_pastCode3,
+                   icq_affiliations_pastKeyword1,
+                   icq_affiliations_pastKeyword2,
+                   icq_affiliations_pastKeyword3,
+                   icq_basicInfo_address,
+                   icq_basicInfo_cellPhone,
+                   icq_basicInfo_city,
+                   icq_basicInfo_countryCode,
+                   icq_basicInfo_emailAddress,
+                   icq_basicInfo_fax,
+                   icq_basicInfo_firstName,
+                   icq_basicInfo_gmtOffset,
+                   icq_basicInfo_lastName,
+                   icq_basicInfo_nickName,
+                   icq_basicInfo_phone,
+                   icq_basicInfo_publishEmail,
+                   icq_basicInfo_state,
+                   icq_basicInfo_zipCode,
+                   icq_interests_code1,
+                   icq_interests_code2,
+                   icq_interests_code3,
+                   icq_interests_code4,
+                   icq_interests_keyword1,
+                   icq_interests_keyword2,
+                   icq_interests_keyword3,
+                   icq_interests_keyword4,
+                   icq_moreInfo_birthDay,
+                   icq_moreInfo_birthMonth,
+                   icq_moreInfo_birthYear,
+                   icq_moreInfo_gender,
+                   icq_moreInfo_homePageAddr,
+                   icq_moreInfo_lang1,
+                   icq_moreInfo_lang2,
+                   icq_moreInfo_lang3,
+                   icq_notes,
+                   icq_permissions_authRequired,
+                   icq_workInfo_address,
+                   icq_workInfo_city,
+                   icq_workInfo_company,
+                   icq_workInfo_countryCode,
+                   icq_workInfo_department,
+                   icq_workInfo_fax,
+                   icq_workInfo_occupationCode,
+                   icq_workInfo_phone,
+                   icq_workInfo_position,
+                   icq_workInfo_state,
+                   icq_workInfo_webPage,
+                   icq_workInfo_zipCode)
+SELECT identScreenName,
+       displayScreenName,
+       authKey,
+       strongMD5Pass,
+       weakMD5Pass,
+       confirmStatus,
+       emailAddress,
+       regStatus,
+       isICQ,
+       icq_affiliations_currentCode1,
+       icq_affiliations_currentCode2,
+       icq_affiliations_currentCode3,
+       icq_affiliations_currentKeyword1,
+       icq_affiliations_currentKeyword2,
+       icq_affiliations_currentKeyword3,
+       icq_affiliations_pastCode1,
+       icq_affiliations_pastCode2,
+       icq_affiliations_pastCode3,
+       icq_affiliations_pastKeyword1,
+       icq_affiliations_pastKeyword2,
+       icq_affiliations_pastKeyword3,
+       icq_basicInfo_address,
+       icq_basicInfo_cellPhone,
+       icq_basicInfo_city,
+       icq_basicInfo_countryCode,
+       icq_basicInfo_emailAddress,
+       icq_basicInfo_fax,
+       icq_basicInfo_firstName,
+       icq_basicInfo_gmtOffset,
+       icq_basicInfo_lastName,
+       icq_basicInfo_nickName,
+       icq_basicInfo_phone,
+       icq_basicInfo_publishEmail,
+       icq_basicInfo_state,
+       icq_basicInfo_zipCode,
+       icq_interests_code1,
+       icq_interests_code2,
+       icq_interests_code3,
+       icq_interests_code4,
+       icq_interests_keyword1,
+       icq_interests_keyword2,
+       icq_interests_keyword3,
+       icq_interests_keyword4,
+       icq_moreInfo_birthDay,
+       icq_moreInfo_birthMonth,
+       icq_moreInfo_birthYear,
+       icq_moreInfo_gender,
+       icq_moreInfo_homePageAddr,
+       icq_moreInfo_lang1,
+       icq_moreInfo_lang2,
+       icq_moreInfo_lang3,
+       icq_notes,
+       icq_permissions_authRequired,
+       icq_workInfo_address,
+       icq_workInfo_city,
+       icq_workInfo_company,
+       icq_workInfo_countryCode,
+       icq_workInfo_department,
+       icq_workInfo_fax,
+       icq_workInfo_occupationCode,
+       icq_workInfo_phone,
+       icq_workInfo_position,
+       icq_workInfo_state,
+       icq_workInfo_webPage,
+       icq_workInfo_zipCode
+FROM users_new;
+
+DROP TABLE users_new;
+
+DROP TABLE aimKeyword;
+DROP TABLE aimKeywordCategory;

+ 270 - 0
state/migrations/0009_aim_directory.up.sql

@@ -0,0 +1,270 @@
+CREATE TABLE aimKeywordCategory
+(
+    id   INTEGER PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE aimKeyword
+(
+    id     INTEGER PRIMARY KEY,
+    name   TEXT NOT NULL UNIQUE,
+    parent INTEGER,
+    FOREIGN KEY (parent) REFERENCES aimKeywordCategory (id) ON DELETE CASCADE
+);
+
+ALTER TABLE users
+    RENAME TO users_old;
+
+CREATE TABLE users
+(
+    identScreenName                  VARCHAR(16) PRIMARY KEY,
+    displayScreenName                TEXT,
+    authKey                          TEXT,
+    strongMD5Pass                    TEXT,
+    weakMD5Pass                      TEXT,
+    confirmStatus                    BOOLEAN               DEFAULT FALSE,
+    emailAddress                     VARCHAR(320) NOT NULL DEFAULT '',
+    regStatus                        INT          NOT NULL DEFAULT 3,
+    isICQ                            BOOLEAN      NOT NULL DEFAULT false,
+    aim_firstName                    TEXT         NOT NULL DEFAULT '',
+    aim_lastName                     TEXT         NOT NULL DEFAULT '',
+    aim_middleName                   TEXT         NOT NULL DEFAULT '',
+    aim_maidenName                   TEXT         NOT NULL DEFAULT '',
+    aim_country                      TEXT         NOT NULL DEFAULT '',
+    aim_state                        TEXT         NOT NULL DEFAULT '',
+    aim_city                         TEXT         NOT NULL DEFAULT '',
+    aim_nickName                     TEXT         NOT NULL DEFAULT '',
+    aim_zipCode                      TEXT         NOT NULL DEFAULT '',
+    aim_address                      TEXT         NOT NULL DEFAULT '',
+    aim_keyword1                     INTEGER,
+    aim_keyword2                     INTEGER,
+    aim_keyword3                     INTEGER,
+    aim_keyword4                     INTEGER,
+    aim_keyword5                     INTEGER,
+    icq_affiliations_currentCode1    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentCode2    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentCode3    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentKeyword1 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_currentKeyword2 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_currentKeyword3 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastCode1       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastCode2       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastCode3       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastKeyword1    TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastKeyword2    TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastKeyword3    TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_address            TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_cellPhone          TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_city               TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_countryCode        INTEGER      NOT NULL DEFAULT 0,
+    icq_basicInfo_emailAddress       TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_fax                TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_firstName          TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_gmtOffset          INTEGER      NOT NULL DEFAULT 0,
+    icq_basicInfo_lastName           TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_nickName           TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_phone              TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_publishEmail       BOOLEAN      NOT NULL DEFAULT false,
+    icq_basicInfo_state              TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_zipCode            TEXT         NOT NULL DEFAULT '',
+    icq_interests_code1              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code2              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code3              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code4              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_keyword1           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword2           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword3           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword4           TEXT         NOT NULL DEFAULT '',
+    icq_moreInfo_birthDay            INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_birthMonth          INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_birthYear           INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_gender              INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_homePageAddr        TEXT         NOT NULL DEFAULT '',
+    icq_moreInfo_lang1               INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_lang2               INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_lang3               INTEGER      NOT NULL DEFAULT 0,
+    icq_notes                        TEXT         NOT NULL DEFAULT '',
+    icq_permissions_authRequired     BOOLEAN      NOT NULL DEFAULT false,
+    icq_workInfo_address             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_city                TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_company             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_countryCode         INTEGER      NOT NULL DEFAULT 0,
+    icq_workInfo_department          TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_fax                 TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_occupationCode      INTEGER      NOT NULL DEFAULT 0,
+    icq_workInfo_phone               TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_position            TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_state               TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_webPage             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_zipCode             TEXT         NOT NULL DEFAULT '',
+
+    FOREIGN KEY (aim_keyword1) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword2) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword3) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword4) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword5) REFERENCES aimKeyword (id)
+);
+
+INSERT INTO users (identScreenName,
+                   displayScreenName,
+                   authKey,
+                   strongMD5Pass,
+                   weakMD5Pass,
+                   confirmStatus,
+                   emailAddress,
+                   regStatus,
+                   isICQ,
+                   aim_firstName,
+                   aim_lastName,
+                   aim_middleName,
+                   aim_maidenName,
+                   aim_country,
+                   aim_state,
+                   aim_city,
+                   aim_nickName,
+                   aim_zipCode,
+                   aim_address,
+                   aim_keyword1,
+                   aim_keyword2,
+                   aim_keyword3,
+                   aim_keyword4,
+                   aim_keyword5,
+                   icq_affiliations_currentCode1,
+                   icq_affiliations_currentCode2,
+                   icq_affiliations_currentCode3,
+                   icq_affiliations_currentKeyword1,
+                   icq_affiliations_currentKeyword2,
+                   icq_affiliations_currentKeyword3,
+                   icq_affiliations_pastCode1,
+                   icq_affiliations_pastCode2,
+                   icq_affiliations_pastCode3,
+                   icq_affiliations_pastKeyword1,
+                   icq_affiliations_pastKeyword2,
+                   icq_affiliations_pastKeyword3,
+                   icq_basicInfo_address,
+                   icq_basicInfo_cellPhone,
+                   icq_basicInfo_city,
+                   icq_basicInfo_countryCode,
+                   icq_basicInfo_emailAddress,
+                   icq_basicInfo_fax,
+                   icq_basicInfo_firstName,
+                   icq_basicInfo_gmtOffset,
+                   icq_basicInfo_lastName,
+                   icq_basicInfo_nickName,
+                   icq_basicInfo_phone,
+                   icq_basicInfo_publishEmail,
+                   icq_basicInfo_state,
+                   icq_basicInfo_zipCode,
+                   icq_interests_code1,
+                   icq_interests_code2,
+                   icq_interests_code3,
+                   icq_interests_code4,
+                   icq_interests_keyword1,
+                   icq_interests_keyword2,
+                   icq_interests_keyword3,
+                   icq_interests_keyword4,
+                   icq_moreInfo_birthDay,
+                   icq_moreInfo_birthMonth,
+                   icq_moreInfo_birthYear,
+                   icq_moreInfo_gender,
+                   icq_moreInfo_homePageAddr,
+                   icq_moreInfo_lang1,
+                   icq_moreInfo_lang2,
+                   icq_moreInfo_lang3,
+                   icq_notes,
+                   icq_permissions_authRequired,
+                   icq_workInfo_address,
+                   icq_workInfo_city,
+                   icq_workInfo_company,
+                   icq_workInfo_countryCode,
+                   icq_workInfo_department,
+                   icq_workInfo_fax,
+                   icq_workInfo_occupationCode,
+                   icq_workInfo_phone,
+                   icq_workInfo_position,
+                   icq_workInfo_state,
+                   icq_workInfo_webPage,
+                   icq_workInfo_zipCode)
+SELECT identScreenName,
+       displayScreenName,
+       authKey,
+       strongMD5Pass,
+       weakMD5Pass,
+       confirmStatus,
+       emailAddress,
+       regStatus,
+       isICQ,
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       NULL,
+       NULL,
+       NULL,
+       NULL,
+       NULL,
+       icq_affiliations_currentCode1,
+       icq_affiliations_currentCode2,
+       icq_affiliations_currentCode3,
+       icq_affiliations_currentKeyword1,
+       icq_affiliations_currentKeyword2,
+       icq_affiliations_currentKeyword3,
+       icq_affiliations_pastCode1,
+       icq_affiliations_pastCode2,
+       icq_affiliations_pastCode3,
+       icq_affiliations_pastKeyword1,
+       icq_affiliations_pastKeyword2,
+       icq_affiliations_pastKeyword3,
+       icq_basicInfo_address,
+       icq_basicInfo_cellPhone,
+       icq_basicInfo_city,
+       icq_basicInfo_countryCode,
+       icq_basicInfo_emailAddress,
+       icq_basicInfo_fax,
+       icq_basicInfo_firstName,
+       icq_basicInfo_gmtOffset,
+       icq_basicInfo_lastName,
+       icq_basicInfo_nickName,
+       icq_basicInfo_phone,
+       icq_basicInfo_publishEmail,
+       icq_basicInfo_state,
+       icq_basicInfo_zipCode,
+       icq_interests_code1,
+       icq_interests_code2,
+       icq_interests_code3,
+       icq_interests_code4,
+       icq_interests_keyword1,
+       icq_interests_keyword2,
+       icq_interests_keyword3,
+       icq_interests_keyword4,
+       icq_moreInfo_birthDay,
+       icq_moreInfo_birthMonth,
+       icq_moreInfo_birthYear,
+       icq_moreInfo_gender,
+       icq_moreInfo_homePageAddr,
+       icq_moreInfo_lang1,
+       icq_moreInfo_lang2,
+       icq_moreInfo_lang3,
+       icq_notes,
+       icq_permissions_authRequired,
+       icq_workInfo_address,
+       icq_workInfo_city,
+       icq_workInfo_company,
+       icq_workInfo_countryCode,
+       icq_workInfo_department,
+       icq_workInfo_fax,
+       icq_workInfo_occupationCode,
+       icq_workInfo_phone,
+       icq_workInfo_position,
+       icq_workInfo_state,
+       icq_workInfo_webPage,
+       icq_workInfo_zipCode
+FROM users_old;
+
+DROP TABLE users_old;

+ 42 - 0
state/user.go

@@ -209,6 +209,32 @@ type User struct {
 	// ICQWorkInfo contains the user's professional information, including
 	// their workplace address and job-related details.
 	ICQWorkInfo ICQWorkInfo
+
+	AIMDirectoryInfo AIMNameAndAddr
+}
+
+// AIMNameAndAddr holds name and address AIM directory information.
+type AIMNameAndAddr struct {
+	// FirstName is the user's first name.
+	FirstName string
+	// LastName is the user's last name.
+	LastName string
+	// MiddleName is the user's middle name.
+	MiddleName string
+	// MaidenName is the user's maiden name.
+	MaidenName string
+	// Country is the user's country of residence.
+	Country string
+	// State is the user's state or region of residence.
+	State string
+	// City is the user's city of residence.
+	City string
+	// NickName is the user's chosen nickname.
+	NickName string
+	// ZIPCode is the user's postal or ZIP code.
+	ZIPCode string
+	// Address is the user's street address.
+	Address string
 }
 
 // ICQBasicInfo holds basic information about an ICQ user, including their name, contact details, and location.
@@ -434,3 +460,19 @@ type OfflineMessage struct {
 	Message   wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
 	Sent      time.Time
 }
+
+// Category represents an AIM directory category.
+type Category struct {
+	// ID is the category ID
+	ID uint8
+	// Name is the category name
+	Name string `oscar:"len_prefix=uint16"`
+}
+
+// Keyword represents an AIM directory keyword.
+type Keyword struct {
+	// ID is the keyword ID
+	ID uint8
+	// Name is the keyword name
+	Name string `oscar:"len_prefix=uint16"`
+}

+ 502 - 18
state/user_store.go

@@ -7,18 +7,30 @@ import (
 	"errors"
 	"fmt"
 	"io/fs"
+	"math"
 	"net/http"
 	"net/mail"
 	"strconv"
 	"strings"
 	"time"
 
-	"github.com/mk6i/retro-aim-server/wire"
-
 	"github.com/golang-migrate/migrate/v4"
 	migratesqlite "github.com/golang-migrate/migrate/v4/database/sqlite"
 	"github.com/golang-migrate/migrate/v4/source/httpfs"
-	_ "modernc.org/sqlite"
+	"modernc.org/sqlite"
+	lib "modernc.org/sqlite/lib"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+var (
+	ErrKeywordCategoryExists   = errors.New("keyword category already exists")
+	ErrKeywordCategoryNotFound = errors.New("keyword category not found")
+	ErrKeywordExists           = errors.New("keyword already exists")
+	ErrKeywordInUse            = errors.New("can't delete keyword that is associated with a user")
+	ErrKeywordNotFound         = errors.New("keyword not found")
+	errTooManyCategories       = errors.New("there are too many keyword categories")
+	errTooManyKeywords         = errors.New("there are too many keywords")
 )
 
 //go:embed migrations/*
@@ -34,7 +46,7 @@ type SQLiteUserStore struct {
 // database does not already exist, a new one is created with the required
 // schema.
 func NewSQLiteUserStore(dbFilePath string) (*SQLiteUserStore, error) {
-	db, err := sql.Open("sqlite", dbFilePath)
+	db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=foreign_keys=on", dbFilePath))
 	if err != nil {
 		return nil, err
 	}
@@ -130,11 +142,25 @@ func (f SQLiteUserStore) FindByUIN(UIN uint32) (User, error) {
 	return users[0], nil
 }
 
-// FindByEmail returns a user with a matching email address.
-func (f SQLiteUserStore) FindByEmail(email string) (User, error) {
+// FindByICQEmail returns a user with a matching email address.
+func (f SQLiteUserStore) FindByICQEmail(email string) (User, error) {
 	users, err := f.queryUsers(`icq_basicInfo_emailAddress = ?`, []any{email})
 	if err != nil {
-		return User{}, fmt.Errorf("FindByEmail: %w", err)
+		return User{}, fmt.Errorf("FindByICQEmail: %w", err)
+	}
+
+	if len(users) == 0 {
+		return User{}, ErrNoUser
+	}
+
+	return users[0], nil
+}
+
+// FindByAIMEmail returns a user with a matching email address.
+func (f SQLiteUserStore) FindByAIMEmail(email string) (User, error) {
+	users, err := f.queryUsers(`emailAddress = ?`, []any{email})
+	if err != nil {
+		return User{}, fmt.Errorf("FindByAIMEmail: %w", err)
 	}
 
 	if len(users) == 0 {
@@ -144,9 +170,23 @@ func (f SQLiteUserStore) FindByEmail(email string) (User, error) {
 	return users[0], nil
 }
 
-// FindByDetails returns users with either a matching first name, last name,
-// and nickname. Empty values are not included in the search parameters.
-func (f SQLiteUserStore) FindByDetails(firstName, lastName, nickName string) ([]User, error) {
+// FindByAIMKeyword returns users who have a matching keyword.
+func (f SQLiteUserStore) FindByAIMKeyword(keyword string) ([]User, error) {
+	where := `
+		(SELECT id FROM aimKeyword WHERE name = ?) IN
+		(aim_keyword1, aim_keyword2, aim_keyword3, aim_keyword4, aim_keyword5)
+	`
+	users, err := f.queryUsers(where, []any{keyword})
+	if err != nil {
+		return nil, err
+	}
+
+	return users, nil
+}
+
+// FindByICQName returns users with matching first name, last name, and
+// nickname. Empty values are not included in the search parameters.
+func (f SQLiteUserStore) FindByICQName(firstName, lastName, nickName string) ([]User, error) {
 	var args []any
 	var clauses []string
 
@@ -169,14 +209,80 @@ func (f SQLiteUserStore) FindByDetails(firstName, lastName, nickName string) ([]
 
 	users, err := f.queryUsers(whereClause, args)
 	if err != nil {
-		err = fmt.Errorf("FindByDetails: %w", err)
+		err = fmt.Errorf("FindByICQName: %w", err)
+	}
+
+	return users, nil
+}
+
+// FindByAIMNameAndAddr returns users with all matching non-empty directory info
+// fields. Empty values are not included in the search parameters.
+func (f SQLiteUserStore) FindByAIMNameAndAddr(info AIMNameAndAddr) ([]User, error) {
+	var args []any
+	var clauses []string
+
+	if info.FirstName != "" {
+		args = append(args, info.FirstName)
+		clauses = append(clauses, `LOWER(aim_firstName) = LOWER(?)`)
+	}
+
+	if info.LastName != "" {
+		args = append(args, info.LastName)
+		clauses = append(clauses, `LOWER(aim_lastName) = LOWER(?)`)
+	}
+
+	if info.MiddleName != "" {
+		args = append(args, info.MiddleName)
+		clauses = append(clauses, `LOWER(aim_middleName) = LOWER(?)`)
+	}
+
+	if info.MaidenName != "" {
+		args = append(args, info.MaidenName)
+		clauses = append(clauses, `LOWER(aim_maidenName) = LOWER(?)`)
+	}
+
+	if info.Country != "" {
+		args = append(args, info.Country)
+		clauses = append(clauses, `LOWER(aim_country) = LOWER(?)`)
+	}
+
+	if info.State != "" {
+		args = append(args, info.State)
+		clauses = append(clauses, `LOWER(aim_state) = LOWER(?)`)
+	}
+
+	if info.City != "" {
+		args = append(args, info.City)
+		clauses = append(clauses, `LOWER(aim_city) = LOWER(?)`)
+	}
+
+	if info.NickName != "" {
+		args = append(args, info.NickName)
+		clauses = append(clauses, `LOWER(aim_nickName) = LOWER(?)`)
+	}
+
+	if info.ZIPCode != "" {
+		args = append(args, info.ZIPCode)
+		clauses = append(clauses, `LOWER(aim_zipCode) = LOWER(?)`)
+	}
+
+	if info.Address != "" {
+		args = append(args, info.Address)
+		clauses = append(clauses, `LOWER(aim_address) = LOWER(?)`)
+	}
+
+	whereClause := strings.Join(clauses, " AND ")
+
+	users, err := f.queryUsers(whereClause, args)
+	if err != nil {
+		err = fmt.Errorf("FindByAIMNameAndAddr: %w", err)
 	}
 
 	return users, nil
 }
 
-// FindByInterests returns users who have at least one matching interest.
-func (f SQLiteUserStore) FindByInterests(code uint16, keywords []string) ([]User, error) {
+// FindByICQInterests returns users who have at least one matching interest.
+func (f SQLiteUserStore) FindByICQInterests(code uint16, keywords []string) ([]User, error) {
 	var args []any
 	var clauses []string
 
@@ -194,15 +300,15 @@ func (f SQLiteUserStore) FindByInterests(code uint16, keywords []string) ([]User
 
 	users, err := f.queryUsers(cond, args)
 	if err != nil {
-		err = fmt.Errorf("FindByInterests: %w", err)
+		err = fmt.Errorf("FindByICQInterests: %w", err)
 	}
 
 	return users, nil
 }
 
-// FindByKeyword returns users with matching interest keyword across all
+// FindByICQKeyword returns users with matching interest keyword across all
 // interest categories.
-func (f SQLiteUserStore) FindByKeyword(keyword string) ([]User, error) {
+func (f SQLiteUserStore) FindByICQKeyword(keyword string) ([]User, error) {
 	var args []any
 	var clauses []string
 
@@ -215,7 +321,7 @@ func (f SQLiteUserStore) FindByKeyword(keyword string) ([]User, error) {
 
 	users, err := f.queryUsers(whereClause, args)
 	if err != nil {
-		err = fmt.Errorf("FindByKeyword: %w", err)
+		err = fmt.Errorf("FindByICQKeyword: %w", err)
 	}
 
 	return users, nil
@@ -306,7 +412,17 @@ func (f SQLiteUserStore) queryUsers(whereClause string, queryParams []any) ([]Us
 			icq_workInfo_position,
 			icq_workInfo_state,
 			icq_workInfo_webPage,
-			icq_workInfo_zipCode
+			icq_workInfo_zipCode,
+			aim_firstName,
+			aim_lastName,
+			aim_middleName,
+			aim_maidenName,
+			aim_country,
+			aim_state,
+			aim_city,
+			aim_nickName,
+			aim_zipCode,
+			aim_address
 		FROM users
 		WHERE %s
 	`
@@ -387,6 +503,16 @@ func (f SQLiteUserStore) queryUsers(whereClause string, queryParams []any) ([]Us
 			&u.ICQWorkInfo.State,
 			&u.ICQWorkInfo.WebPage,
 			&u.ICQWorkInfo.ZIPCode,
+			&u.AIMDirectoryInfo.FirstName,
+			&u.AIMDirectoryInfo.LastName,
+			&u.AIMDirectoryInfo.MiddleName,
+			&u.AIMDirectoryInfo.MaidenName,
+			&u.AIMDirectoryInfo.Country,
+			&u.AIMDirectoryInfo.State,
+			&u.AIMDirectoryInfo.City,
+			&u.AIMDirectoryInfo.NickName,
+			&u.AIMDirectoryInfo.ZIPCode,
+			&u.AIMDirectoryInfo.Address,
 		)
 		if err != nil {
 			return nil, err
@@ -765,6 +891,50 @@ func (f SQLiteUserStore) SetProfile(screenName IdentScreenName, body string) err
 	return err
 }
 
+// SetDirectoryInfo sets an AIM user's directory information.
+func (f SQLiteUserStore) SetDirectoryInfo(screenName IdentScreenName, info AIMNameAndAddr) error {
+	q := `
+		UPDATE users SET 
+			aim_firstName = ?,
+			aim_lastName = ?,
+			aim_middleName = ?,
+			aim_maidenName = ?,
+			aim_country = ?,
+			aim_state = ?,
+			aim_city = ?,
+			aim_nickName = ?,
+			aim_zipCode = ?,
+			aim_address = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		info.FirstName,
+		info.LastName,
+		info.MiddleName,
+		info.MaidenName,
+		info.Country,
+		info.State,
+		info.City,
+		info.NickName,
+		info.ZIPCode,
+		info.Address,
+		screenName.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+
+	return nil
+}
+
 func (f SQLiteUserStore) BARTUpsert(itemHash []byte, body []byte) error {
 	q := `
 		INSERT INTO bartItem (hash, body)
@@ -1349,3 +1519,317 @@ func (f SQLiteUserStore) BuddyIconRefByName(screenName IdentScreenName) (*wire.B
 	}, nil
 
 }
+
+func (f SQLiteUserStore) SetKeywords(name IdentScreenName, keywords [5]string) error {
+	q := `
+		WITH interests AS (SELECT CASE WHEN name = ? THEN id END AS aim_keyword1,
+								  CASE WHEN name = ? THEN id END AS aim_keyword2,
+								  CASE WHEN name = ? THEN id END AS aim_keyword3,
+								  CASE WHEN name = ? THEN id END AS aim_keyword4,
+								  CASE WHEN name = ? THEN id END AS aim_keyword5
+						   FROM aimKeyword
+						   WHERE name IN (?, ?, ?, ?, ?))
+		UPDATE users
+		SET aim_keyword1 = (SELECT aim_keyword1 FROM interests WHERE aim_keyword1),
+			aim_keyword2 = (SELECT aim_keyword2 FROM interests WHERE aim_keyword2),
+			aim_keyword3 = (SELECT aim_keyword3 FROM interests WHERE aim_keyword3),
+			aim_keyword4 = (SELECT aim_keyword4 FROM interests WHERE aim_keyword4),
+			aim_keyword5 = (SELECT aim_keyword5 FROM interests WHERE aim_keyword5)
+		WHERE identScreenName = ?
+	`
+
+	_, err := f.db.Exec(q,
+		keywords[0], keywords[1], keywords[2], keywords[3], keywords[4],
+		keywords[0], keywords[1], keywords[2], keywords[3], keywords[4],
+		name.String())
+	return err
+}
+
+// Categories returns a list of keyword categories.
+func (f SQLiteUserStore) Categories() ([]Category, error) {
+	q := `SELECT id, name FROM aimKeywordCategory ORDER BY name`
+
+	rows, err := f.db.Query(q)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var categories []Category
+	for rows.Next() {
+		category := Category{}
+		if err := rows.Scan(&category.ID, &category.Name); err != nil {
+			return nil, err
+		}
+		categories = append(categories, category)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return categories, nil
+}
+
+// CreateCategory creates a new keyword category.
+func (f SQLiteUserStore) CreateCategory(name string) (Category, error) {
+	tx, err := f.db.Begin()
+	if err != nil {
+		return Category{}, err
+	}
+
+	defer tx.Rollback()
+
+	q := `INSERT INTO aimKeywordCategory (name) VALUES (?)`
+	res, err := tx.Exec(q, name)
+	if err != nil {
+		if sqliteErr, ok := err.(*sqlite.Error); ok && sqliteErr.Code() == lib.SQLITE_CONSTRAINT_UNIQUE {
+			err = ErrKeywordCategoryExists
+		}
+		return Category{}, err
+	}
+
+	id, err := res.LastInsertId()
+	if err != nil {
+		return Category{}, err
+	}
+
+	if id > math.MaxUint8 {
+		return Category{}, errTooManyCategories
+	}
+
+	if err := tx.Commit(); err != nil {
+		return Category{}, err
+	}
+
+	return Category{
+		ID:   uint8(id),
+		Name: name,
+	}, nil
+}
+
+// DeleteCategory deletes a keyword category and all of its associated
+// keywords.
+func (f SQLiteUserStore) DeleteCategory(categoryID uint8) error {
+	q := `DELETE FROM aimKeywordCategory WHERE id = ?`
+	res, err := f.db.Exec(q, categoryID)
+	if err != nil {
+		// Check if the error is a foreign key constraint violation
+		if sqliteErr, ok := err.(*sqlite.Error); ok && sqliteErr.Code() == lib.SQLITE_CONSTRAINT_FOREIGNKEY {
+			return ErrKeywordInUse
+		}
+	}
+
+	c, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if c == 0 {
+		return ErrKeywordCategoryNotFound
+	}
+
+	return nil
+}
+
+// KeywordsByCategory returns all keywords for a given category.
+func (f SQLiteUserStore) KeywordsByCategory(categoryID uint8) ([]Keyword, error) {
+	q := `SELECT id, name FROM aimKeyword WHERE parent = ? ORDER BY name`
+	if categoryID == 0 {
+		q = `SELECT id, name FROM aimKeyword WHERE parent IS NULL ORDER BY name`
+	}
+
+	rows, err := f.db.Query(q, categoryID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var keywords []Keyword
+	for rows.Next() {
+		keyword := Keyword{}
+		if err := rows.Scan(&keyword.ID, &keyword.Name); err != nil {
+			return nil, err
+		}
+		keywords = append(keywords, keyword)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	if len(keywords) == 0 {
+		var exists int
+		err = f.db.QueryRow("SELECT COUNT(*) FROM aimKeywordCategory WHERE id = ?", categoryID).Scan(&exists)
+		if err != nil {
+			return nil, err
+		}
+		if exists == 0 {
+			return nil, ErrKeywordCategoryNotFound
+		}
+	}
+
+	return keywords, nil
+}
+
+// CreateKeyword creates a new keyword. If categoryID is 0, it has no category.
+func (f SQLiteUserStore) CreateKeyword(name string, categoryID uint8) (Keyword, error) {
+	tx, err := f.db.Begin()
+	if err != nil {
+		return Keyword{}, err
+	}
+
+	defer tx.Rollback()
+
+	q := `INSERT INTO aimKeyword (name, parent) VALUES (?, ?)`
+	var parent interface{} = nil
+	if categoryID != 0 {
+		parent = categoryID
+	}
+
+	res, err := tx.Exec(q, name, parent)
+	if err != nil {
+		if sqliteErr, ok := err.(*sqlite.Error); ok && sqliteErr.Code() == lib.SQLITE_CONSTRAINT_UNIQUE {
+			err = ErrKeywordExists
+		} else if sqliteErr, ok := err.(*sqlite.Error); ok && sqliteErr.Code() == lib.SQLITE_CONSTRAINT_FOREIGNKEY {
+			err = ErrKeywordCategoryNotFound
+		}
+		return Keyword{}, err
+	}
+
+	id, err := res.LastInsertId()
+	if err != nil {
+		return Keyword{}, err
+	}
+
+	if id > math.MaxUint8 {
+		return Keyword{}, errTooManyKeywords
+	}
+
+	if err := tx.Commit(); err != nil {
+		return Keyword{}, err
+	}
+
+	return Keyword{
+		ID:   uint8(id),
+		Name: name,
+	}, nil
+}
+
+// DeleteKeyword deletes a keyword.
+func (f SQLiteUserStore) DeleteKeyword(id uint8) error {
+	q := `DELETE FROM aimKeyword WHERE id = ?`
+	res, err := f.db.Exec(q, id)
+
+	if err != nil {
+		// Check if the error is a foreign key constraint violation
+		if sqliteErr, ok := err.(*sqlite.Error); ok && sqliteErr.Code() == lib.SQLITE_CONSTRAINT_FOREIGNKEY {
+			return ErrKeywordInUse
+		}
+	}
+
+	c, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if c == 0 {
+		return ErrKeywordNotFound
+	}
+
+	return nil
+}
+
+// InterestList returns a list of keywords grouped by category used to render
+// the AIM directory interests list. The list is made up of 3 types of elements:
+//
+// Categories
+//
+//	ID: The category ID
+//	Name: The category name
+//	Type: [wire.ODirKeywordCategory]
+//
+// Keywords
+//
+//	ID: The parent category ID
+//	Name: The keyword name
+//	Type: [wire.ODirKeyword]
+//
+// Top-level Keywords
+//
+//	ID: 0 (does not have a parent category)
+//	Name: The keyword name
+//	Type: [wire.ODirKeyword]
+//
+// Keywords are grouped contiguously by category and preceded by the category
+// name. Top-level keywords appear by themselves. Categories and top-level
+// keywords are sorted alphabetically. Keyword groups are sorted alphabetically.
+//
+// Conceptually, the list looks like this:
+//
+//	> Animals (top-level keyword, id=0)
+//	> Music (category, id=1)
+//		> Jazz (keyword, id=1)
+//		> Rock (keyword, id=1)
+//	> Sports (category, id=2)
+//		> Basketball (keyword, id=2)
+//		> Soccer (keyword, id=2)
+//		> Tennis (keyword, id=2)
+//	> Technology (category, id=3)
+//	> Artificial Intelligence (keyword, id=3)
+//		> Cybersecurity (keyword, id=3)
+//	> Zoology (top-level keyword, id=0)
+func (f SQLiteUserStore) InterestList() ([]wire.ODirKeywordListItem, error) {
+	q := `
+		WITH categories AS (
+			SELECT
+				name AS grouping,
+				id,
+				0 AS sortPrio,
+				name
+			FROM aimKeywordCategory
+			UNION
+			SELECT
+				IFNULL(akc.name, ak.name) AS grouping,
+				IFNULL(ak.parent, 0) AS id,
+				CASE WHEN ak.parent IS NULL THEN 1 ELSE 2 END AS sortPrio,
+				ak.name
+			FROM aimKeyword ak
+			LEFT JOIN aimKeywordCategory akc ON akc.id = ak.parent
+			ORDER BY 1, 3, 4
+		)
+		SELECT
+			id,
+			sortPrio,
+			name
+		FROM categories
+	`
+
+	rows, err := f.db.Query(q)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var list []wire.ODirKeywordListItem
+	for rows.Next() {
+		msg := wire.ODirKeywordListItem{}
+
+		var sortPrio int
+		if err := rows.Scan(&msg.ID, &sortPrio, &msg.Name); err != nil {
+			return nil, err
+		}
+		switch sortPrio {
+		case 0:
+			msg.Type = wire.ODirKeywordCategory
+		case 1, 2:
+			msg.Type = wire.ODirKeyword
+		}
+
+		list = append(list, msg)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return list, nil
+}

+ 853 - 20
state/user_store_test.go

@@ -1,6 +1,9 @@
 package state
 
 import (
+	"fmt"
+	"math"
+	"net/mail"
 	"os"
 	"reflect"
 	"testing"
@@ -1341,7 +1344,7 @@ func TestSQLiteUserStore_SetBasicInfo(t *testing.T) {
 	})
 }
 
-func TestSQLiteUserStore_FindByInterests(t *testing.T) {
+func TestSQLiteUserStore_FindByICQInterests(t *testing.T) {
 	// Cleanup after test
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))
@@ -1406,7 +1409,7 @@ func TestSQLiteUserStore_FindByInterests(t *testing.T) {
 
 	t.Run("Find Users by Single Keyword", func(t *testing.T) {
 		// Search for users interested in "Music"
-		users, err := f.FindByInterests(2, []string{"Music"})
+		users, err := f.FindByICQInterests(2, []string{"Music"})
 		assert.NoError(t, err)
 		assert.Len(t, users, 2)
 
@@ -1417,7 +1420,7 @@ func TestSQLiteUserStore_FindByInterests(t *testing.T) {
 
 	t.Run("Find Users by Multiple Keywords", func(t *testing.T) {
 		// Search for users interested in "Coding" or "Gaming"
-		users, err := f.FindByInterests(1, []string{"Coding", "Gaming"})
+		users, err := f.FindByICQInterests(1, []string{"Coding", "Gaming"})
 		assert.NoError(t, err)
 		assert.Len(t, users, 2)
 
@@ -1428,14 +1431,14 @@ func TestSQLiteUserStore_FindByInterests(t *testing.T) {
 
 	t.Run("Find Users by Multiple Codes and Keywords", func(t *testing.T) {
 		// Search for users interested in "Coding"
-		users, err := f.FindByInterests(1, []string{"Coding"})
+		users, err := f.FindByICQInterests(1, []string{"Coding"})
 		assert.NoError(t, err)
 		assert.Len(t, users, 2)
 		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
 		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
 
 		// Search for users interested in "Travel"
-		users, err = f.FindByInterests(4, []string{"Travel"})
+		users, err = f.FindByICQInterests(4, []string{"Travel"})
 		assert.NoError(t, err)
 		assert.Len(t, users, 1)
 		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
@@ -1443,13 +1446,13 @@ func TestSQLiteUserStore_FindByInterests(t *testing.T) {
 
 	t.Run("No Users Found", func(t *testing.T) {
 		// Search for users interested in a keyword that no user has
-		users, err := f.FindByInterests(1, []string{"Unknown"})
+		users, err := f.FindByICQInterests(1, []string{"Status"})
 		assert.NoError(t, err)
 		assert.Empty(t, users)
 	})
 }
 
-func TestSQLiteUserStore_FindByKeyword(t *testing.T) {
+func TestSQLiteUserStore_FindByICQKeyword(t *testing.T) {
 	// Cleanup after test
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))
@@ -1508,7 +1511,7 @@ func TestSQLiteUserStore_FindByKeyword(t *testing.T) {
 
 	t.Run("Find Users by Keyword", func(t *testing.T) {
 		// Search for users interested in "Music"
-		users, err := f.FindByKeyword("Music")
+		users, err := f.FindByICQKeyword("Music")
 		assert.NoError(t, err)
 		assert.Len(t, users, 2)
 
@@ -1519,13 +1522,13 @@ func TestSQLiteUserStore_FindByKeyword(t *testing.T) {
 
 	t.Run("No Users Found", func(t *testing.T) {
 		// Search for users interested in a keyword that no user has
-		users, err := f.FindByKeyword("Knitting")
+		users, err := f.FindByICQKeyword("Knitting")
 		assert.NoError(t, err)
 		assert.Empty(t, users)
 	})
 }
 
-func TestSQLiteUserStore_FindByDetails(t *testing.T) {
+func TestSQLiteUserStore_FindByICQName(t *testing.T) {
 	// Cleanup after test
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))
@@ -1587,7 +1590,7 @@ func TestSQLiteUserStore_FindByDetails(t *testing.T) {
 
 	t.Run("Find Users by First Name", func(t *testing.T) {
 		// Search for users with the first name "John"
-		users, err := f.FindByDetails("John", "", "")
+		users, err := f.FindByICQName("John", "", "")
 		assert.NoError(t, err)
 		assert.Len(t, users, 2)
 
@@ -1598,7 +1601,7 @@ func TestSQLiteUserStore_FindByDetails(t *testing.T) {
 
 	t.Run("Find Users by Last Name", func(t *testing.T) {
 		// Search for users with the last name "Smith"
-		users, err := f.FindByDetails("", "Smith", "")
+		users, err := f.FindByICQName("", "Smith", "")
 		assert.NoError(t, err)
 		assert.Len(t, users, 2)
 
@@ -1609,7 +1612,7 @@ func TestSQLiteUserStore_FindByDetails(t *testing.T) {
 
 	t.Run("Find Users by Nickname", func(t *testing.T) {
 		// Search for users with the nickname "Johnny"
-		users, err := f.FindByDetails("", "", "Johnny")
+		users, err := f.FindByICQName("", "", "Johnny")
 		assert.NoError(t, err)
 		assert.Len(t, users, 1)
 
@@ -1619,7 +1622,7 @@ func TestSQLiteUserStore_FindByDetails(t *testing.T) {
 
 	t.Run("Find Users by Multiple Fields", func(t *testing.T) {
 		// Search for users with the first name "Jane" and last name "Smith"
-		users, err := f.FindByDetails("Jane", "Smith", "")
+		users, err := f.FindByICQName("Jane", "Smith", "")
 		assert.NoError(t, err)
 		assert.Len(t, users, 1)
 
@@ -1629,13 +1632,136 @@ func TestSQLiteUserStore_FindByDetails(t *testing.T) {
 
 	t.Run("No Users Found", func(t *testing.T) {
 		// Search for users with a first name that no user has
-		users, err := f.FindByDetails("NonExistent", "", "")
+		users, err := f.FindByICQName("NonExistent", "", "")
 		assert.NoError(t, err)
 		assert.Empty(t, users)
 	})
 }
 
-func TestSQLiteUserStore_FindByEmail(t *testing.T) {
+func TestSQLiteUserStore_FindByDirectoryInfo(t *testing.T) {
+	// Cleanup after test
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	// Initialize the SQLiteUserStore with a test database file
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	// Create and set up test users with different directory info
+	user1 := User{
+		IdentScreenName: NewIdentScreenName("user1"),
+	}
+	err = f.InsertUser(user1)
+	assert.NoError(t, err)
+	directoryInfo1 := AIMNameAndAddr{
+		FirstName: "John",
+		LastName:  "Doe",
+		NickName:  "Johnny",
+		City:      "New York",
+	}
+	err = f.SetDirectoryInfo(user1.IdentScreenName, directoryInfo1)
+	assert.NoError(t, err)
+
+	user2 := User{
+		IdentScreenName: NewIdentScreenName("user2"),
+	}
+	err = f.InsertUser(user2)
+	assert.NoError(t, err)
+	directoryInfo2 := AIMNameAndAddr{
+		FirstName: "Jane",
+		LastName:  "Smith",
+		NickName:  "Janey",
+		Country:   "USA",
+	}
+	err = f.SetDirectoryInfo(user2.IdentScreenName, directoryInfo2)
+	assert.NoError(t, err)
+
+	user3 := User{
+		IdentScreenName: NewIdentScreenName("user3"),
+	}
+	err = f.InsertUser(user3)
+	assert.NoError(t, err)
+	directoryInfo3 := AIMNameAndAddr{
+		FirstName: "John",
+		LastName:  "Smith",
+		NickName:  "JohnnyS",
+		State:     "California",
+	}
+	err = f.SetDirectoryInfo(user3.IdentScreenName, directoryInfo3)
+	assert.NoError(t, err)
+
+	// Helper function to check if a user with a specific IdentScreenName exists in the results
+	containsUserWithScreenName := func(users []User, screenName IdentScreenName) bool {
+		for _, user := range users {
+			if user.IdentScreenName == screenName {
+				return true
+			}
+		}
+		return false
+	}
+
+	t.Run("Find Users by First Name", func(t *testing.T) {
+		// Search for users with the first name "John"
+		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{FirstName: "John"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+
+		// Check that the correct users are returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
+	})
+
+	t.Run("Find Users by Last Name", func(t *testing.T) {
+		// Search for users with the last name "Smith"
+		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{LastName: "Smith"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 2)
+
+		// Check that the correct users are returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
+		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
+	})
+
+	t.Run("Find Users by Nickname", func(t *testing.T) {
+		// Search for users with the nickname "Johnny"
+		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{NickName: "Johnny"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 1)
+
+		// Check that the correct user is returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+	})
+
+	t.Run("Find Users by City", func(t *testing.T) {
+		// Search for users with the city "New York"
+		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{City: "New York"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 1)
+
+		// Check that the correct user is returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user1.IdentScreenName))
+	})
+
+	t.Run("Find Users by Multiple Fields", func(t *testing.T) {
+		// Search for users with the first name "Jane" and country "USA"
+		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{FirstName: "Jane", Country: "USA"})
+		assert.NoError(t, err)
+		assert.Len(t, users, 1)
+
+		// Check that the correct user is returned by IdentScreenName
+		assert.True(t, containsUserWithScreenName(users, user2.IdentScreenName))
+	})
+
+	t.Run("No Users Found", func(t *testing.T) {
+		// Search for users with a first name that no user has
+		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{FirstName: "NonExistent"})
+		assert.NoError(t, err)
+		assert.Empty(t, users)
+	})
+}
+
+func TestSQLiteUserStore_FindByICQEmail(t *testing.T) {
 	// Cleanup after test
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))
@@ -1681,24 +1807,82 @@ func TestSQLiteUserStore_FindByEmail(t *testing.T) {
 
 	t.Run("Find User by Email", func(t *testing.T) {
 		// Search for user with email "user1@example.com"
-		user, err := f.FindByEmail("user1@example.com")
+		user, err := f.FindByICQEmail("user1@example.com")
 		assert.NoError(t, err)
 		assert.Equal(t, user1.IdentScreenName, user.IdentScreenName)
 
 		// Search for user with email "user2@example.com"
-		user, err = f.FindByEmail("user2@example.com")
+		user, err = f.FindByICQEmail("user2@example.com")
 		assert.NoError(t, err)
 		assert.Equal(t, user2.IdentScreenName, user.IdentScreenName)
 
 		// Search for user with email "user3@example.com"
-		user, err = f.FindByEmail("user3@example.com")
+		user, err = f.FindByICQEmail("user3@example.com")
 		assert.NoError(t, err)
 		assert.Equal(t, user3.IdentScreenName, user.IdentScreenName)
 	})
 
 	t.Run("User Not Found", func(t *testing.T) {
 		// Search for an email that doesn't exist
-		_, err := f.FindByEmail("nonexistent@example.com")
+		_, err := f.FindByICQEmail("nonexistent@example.com")
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+}
+
+func TestSQLiteUserStore_FindByAIMEmail(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	user1 := User{
+		IdentScreenName: NewIdentScreenName("user1"),
+	}
+	err = f.InsertUser(user1)
+	assert.NoError(t, err)
+	err = f.UpdateEmailAddress(&mail.Address{Address: "user1@example.com"}, user1.IdentScreenName)
+	assert.NoError(t, err)
+
+	user2 := User{
+		IdentScreenName: NewIdentScreenName("user2"),
+		EmailAddress:    "user2@example.com",
+	}
+	err = f.InsertUser(user2)
+	assert.NoError(t, err)
+	err = f.UpdateEmailAddress(&mail.Address{Address: "user2@example.com"}, user2.IdentScreenName)
+	assert.NoError(t, err)
+
+	user3 := User{
+		IdentScreenName: NewIdentScreenName("user3"),
+		EmailAddress:    "user3@example.com",
+	}
+	err = f.InsertUser(user3)
+	assert.NoError(t, err)
+	err = f.UpdateEmailAddress(&mail.Address{Address: "user3@example.com"}, user3.IdentScreenName)
+	assert.NoError(t, err)
+
+	t.Run("Find User by Email", func(t *testing.T) {
+		// Search for user with email "user1@example.com"
+		user, err := f.FindByAIMEmail("user1@example.com")
+		assert.NoError(t, err)
+		assert.Equal(t, user1.IdentScreenName, user.IdentScreenName)
+
+		// Search for user with email "user2@example.com"
+		user, err = f.FindByAIMEmail("user2@example.com")
+		assert.NoError(t, err)
+		assert.Equal(t, user2.IdentScreenName, user.IdentScreenName)
+
+		// Search for user with email "user3@example.com"
+		user, err = f.FindByAIMEmail("user3@example.com")
+		assert.NoError(t, err)
+		assert.Equal(t, user3.IdentScreenName, user.IdentScreenName)
+	})
+
+	t.Run("User Not Found", func(t *testing.T) {
+		// Search for an email that doesn't exist
+		_, err := f.FindByAIMEmail("nonexistent@example.com")
 		assert.ErrorIs(t, err, ErrNoUser)
 	})
 }
@@ -1950,3 +2134,652 @@ func TestSQLiteUserStore_BuddyIconRefByNameMissingRef(t *testing.T) {
 		t.Fatalf("empty BARTID expected")
 	}
 }
+
+func TestSQLiteUserStore_SetDirectoryInfo(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	screenName := NewIdentScreenName("testuser")
+	user := User{
+		IdentScreenName: screenName,
+	}
+	err = f.InsertUser(user)
+	assert.NoError(t, err)
+
+	directoryInfo := AIMNameAndAddr{
+		FirstName:  "John",
+		LastName:   "Doe",
+		MiddleName: "Michael",
+		MaidenName: "Smith",
+		Country:    "USA",
+		State:      "CA",
+		City:       "San Francisco",
+		NickName:   "Johnny",
+		ZIPCode:    "94105",
+		Address:    "123 Main St",
+	}
+
+	t.Run("Successful Update", func(t *testing.T) {
+		err := f.SetDirectoryInfo(screenName, directoryInfo)
+		assert.NoError(t, err)
+
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, directoryInfo.FirstName, updatedUser.AIMDirectoryInfo.FirstName)
+		assert.Equal(t, directoryInfo.LastName, updatedUser.AIMDirectoryInfo.LastName)
+		assert.Equal(t, directoryInfo.MiddleName, updatedUser.AIMDirectoryInfo.MiddleName)
+		assert.Equal(t, directoryInfo.MaidenName, updatedUser.AIMDirectoryInfo.MaidenName)
+		assert.Equal(t, directoryInfo.Country, updatedUser.AIMDirectoryInfo.Country)
+		assert.Equal(t, directoryInfo.State, updatedUser.AIMDirectoryInfo.State)
+		assert.Equal(t, directoryInfo.City, updatedUser.AIMDirectoryInfo.City)
+		assert.Equal(t, directoryInfo.NickName, updatedUser.AIMDirectoryInfo.NickName)
+		assert.Equal(t, directoryInfo.ZIPCode, updatedUser.AIMDirectoryInfo.ZIPCode)
+		assert.Equal(t, directoryInfo.Address, updatedUser.AIMDirectoryInfo.Address)
+	})
+
+	t.Run("Update Non-Existing User", func(t *testing.T) {
+		nonExistingScreenName := NewIdentScreenName("nonexistentuser")
+		err := f.SetDirectoryInfo(nonExistingScreenName, directoryInfo)
+
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+
+	t.Run("Empty Directory Info", func(t *testing.T) {
+		emptyDirectoryInfo := AIMNameAndAddr{}
+		err := f.SetDirectoryInfo(screenName, emptyDirectoryInfo)
+		assert.NoError(t, err)
+
+		updatedUser, err := f.User(screenName)
+		assert.NoError(t, err)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.FirstName)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.LastName)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.MiddleName)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.MaidenName)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.Country)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.State)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.City)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.NickName)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.ZIPCode)
+		assert.Empty(t, updatedUser.AIMDirectoryInfo.Address)
+	})
+}
+
+func TestSQLiteUserStore_Categories(t *testing.T) {
+	t.Run("Retrieve Keyword Categories Successfully", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Insert some test keyword categories
+		categories := []string{"Category3", "Category1", "Category2"}
+		for _, categoryName := range categories {
+			_, err := f.CreateCategory(categoryName)
+			assert.NoError(t, err)
+		}
+
+		retrievedCategories, err := f.Categories()
+		assert.NoError(t, err)
+
+		// Make sure all categories are returned in alphabetical order
+		if assert.Len(t, retrievedCategories, len(categories)) {
+			expect := []Category{
+				{
+					ID:   2,
+					Name: "Category1",
+				},
+				{
+					ID:   3,
+					Name: "Category2",
+				},
+				{
+					ID:   1,
+					Name: "Category3",
+				},
+			}
+			assert.Equal(t, expect, retrievedCategories)
+		}
+	})
+
+	t.Run("No Categories Exist", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Clean up the database
+		_, err = f.db.Exec(`DELETE FROM aimKeywordCategory`)
+		assert.NoError(t, err)
+
+		retrievedCategories, err := f.Categories()
+		assert.NoError(t, err)
+		assert.Empty(t, retrievedCategories)
+	})
+
+	t.Run("SQL Error Handling", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Force an error by querying a non-existent table
+		_, err = f.db.Exec(`DROP TABLE aimKeywordCategory`)
+		assert.NoError(t, err)
+
+		_, err = f.Categories()
+		assert.Error(t, err)
+	})
+
+	t.Run("Unique Constraint Violation", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Insert a category with a unique name
+		categoryName := "UniqueCategory"
+		_, err = f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Try to insert the same category name again to trigger the unique constraint
+		_, err = f.CreateCategory(categoryName)
+		assert.ErrorIs(t, err, ErrKeywordCategoryExists)
+	})
+}
+
+func TestSQLiteUserStore_CreateCategory(t *testing.T) {
+	t.Run("Successfully Create Keyword Category", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		categoryName := "TestCategory"
+		keywordCategory, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		assert.Equal(t, categoryName, keywordCategory.Name)
+		assert.NotZero(t, keywordCategory.ID)
+
+		categories, err := f.Categories()
+		assert.NoError(t, err)
+		if assert.Len(t, categories, 1) {
+			assert.Equal(t, categoryName, categories[0].Name)
+		}
+	})
+
+	t.Run("Duplicate Category Name", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		categoryName := "DuplicateCategory"
+
+		// Create the category
+		_, err = f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Try to create the same category again
+		_, err = f.CreateCategory(categoryName)
+		assert.ErrorIs(t, err, ErrKeywordCategoryExists)
+	})
+
+	t.Run("ID Overflow", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Simulate ID overflow by inserting max number of entries
+		for i := range math.MaxUint8 {
+			_, err := f.CreateCategory(fmt.Sprintf("Category_%d", i))
+			assert.NoError(t, err)
+		}
+
+		// Next insert should cause an ID overflow
+		_, err = f.CreateCategory("OverflowCategory")
+		assert.ErrorIs(t, err, errTooManyCategories)
+	})
+
+	t.Run("SQL Error Handling", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Drop the table to cause an error
+		_, err = f.db.Exec(`DROP TABLE aimKeywordCategory`)
+		assert.NoError(t, err)
+
+		_, err = f.CreateCategory("ShouldFail")
+		assert.Error(t, err)
+	})
+}
+
+func TestSQLiteUserStore_DeleteCategory(t *testing.T) {
+	t.Run("Successfully Delete Keyword Category", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		// Insert a test category
+		categoryName := "CategoryToDelete"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Ensure the category was created
+		retrievedCategories, err := f.Categories()
+		assert.NoError(t, err)
+		assert.Len(t, retrievedCategories, 1)
+
+		// Delete the category
+		err = f.DeleteCategory(category.ID)
+		assert.NoError(t, err)
+
+		// Verify the category was deleted
+		retrievedCategories, err = f.Categories()
+		assert.NoError(t, err)
+		assert.Empty(t, retrievedCategories)
+	})
+
+	t.Run("Delete Non-Existent Category", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		// Attempt to delete a category that does not exist
+		nonExistentCategoryID := uint8(99)
+		err = f.DeleteCategory(nonExistentCategoryID)
+		assert.ErrorIs(t, err, ErrKeywordCategoryNotFound)
+	})
+
+	t.Run("Delete category and all of its keywords", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		// Insert a category
+		categoryName := "CategoryInUse"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Insert a keyword that references this category
+		keywordName := "KeywordInUse"
+		_, err = f.CreateKeyword(keywordName, category.ID)
+		assert.NoError(t, err)
+
+		// Create a user and associate it with the keyword
+		u := User{
+			IdentScreenName: NewIdentScreenName("testuser"),
+		}
+		err = f.InsertUser(u)
+		assert.NoError(t, err)
+
+		err = f.SetKeywords(u.IdentScreenName, [5]string{keywordName})
+		assert.NoError(t, err)
+
+		// Attempt to delete the category that is in use by the keyword
+		err = f.DeleteCategory(category.ID)
+		assert.ErrorIs(t, err, ErrKeywordInUse)
+	})
+}
+
+func TestSQLiteUserStore_CreateKeyword(t *testing.T) {
+	t.Run("Successfully Create Keyword", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Create a test category
+		categoryName := "TestCategory"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Insert a keyword for the category
+		keywordName := "TestKeyword"
+		keyword, err := f.CreateKeyword(keywordName, category.ID)
+		assert.NoError(t, err)
+
+		assert.Equal(t, keywordName, keyword.Name)
+		assert.NotZero(t, keyword.ID)
+
+		// Verify the keyword and category were inserted into the database
+		keywords, err := f.KeywordsByCategory(category.ID)
+		assert.NoError(t, err)
+		if assert.Len(t, keywords, 1) {
+			expect := []Keyword{
+				keyword,
+			}
+			assert.Equal(t, expect, keywords)
+		}
+	})
+
+	t.Run("Create Keyword Without Category", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Insert a keyword with no category (parent is NULL)
+		keywordName := "UncategorizedKeyword"
+		keyword, err := f.CreateKeyword(keywordName, 0)
+		assert.NoError(t, err)
+
+		assert.Equal(t, keywordName, keyword.Name)
+		assert.NotZero(t, keyword.ID)
+
+		// Verify the keyword was inserted into the database
+		keywords, err := f.KeywordsByCategory(0)
+		assert.NoError(t, err)
+		if assert.Len(t, keywords, 1) {
+			expect := []Keyword{
+				keyword,
+			}
+			assert.Equal(t, expect, keywords)
+		}
+	})
+
+	t.Run("Create Keyword With Unknown Category", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Insert a keyword with no category (parent is NULL)
+		keywordName := "AKeyword"
+		_, err = f.CreateKeyword(keywordName, 1)
+		assert.ErrorIs(t, err, ErrKeywordCategoryNotFound)
+	})
+
+	t.Run("Duplicate Keyword Name", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		keywordName := "DuplicateKeyword"
+
+		// Create the keyword
+		_, err = f.CreateKeyword(keywordName, 0)
+		assert.NoError(t, err)
+
+		// Try to create the same keyword again
+		_, err = f.CreateKeyword(keywordName, 0)
+		assert.ErrorIs(t, err, ErrKeywordExists)
+	})
+
+	t.Run("ID Overflow", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Create a test category
+		categoryName := "OverflowCategory"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Simulate ID overflow by inserting max number of entries
+		for i := 0; i < math.MaxUint8; i++ {
+			_, err := f.CreateKeyword(fmt.Sprintf("Keyword_%d", i), category.ID)
+			assert.NoError(t, err)
+		}
+
+		// Next insert should cause an ID overflow
+		_, err = f.CreateKeyword("OverflowKeyword", category.ID)
+		assert.ErrorIs(t, err, errTooManyKeywords)
+	})
+
+	t.Run("SQL Error Handling", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Drop the table to cause an error
+		_, err = f.db.Exec(`DROP TABLE aimKeyword`)
+		assert.NoError(t, err)
+
+		_, err = f.CreateKeyword("ShouldFail", 0)
+		assert.Error(t, err)
+	})
+}
+
+func TestSQLiteUserStore_DeleteKeyword(t *testing.T) {
+	t.Run("Successfully Delete Keyword", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		// Insert a category
+		categoryName := "TestCategory"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Insert a keyword for the category
+		keywordName := "TestKeyword"
+		keyword, err := f.CreateKeyword(keywordName, category.ID)
+		assert.NoError(t, err)
+
+		// Ensure the keyword was created
+		retrievedKeywords, err := f.KeywordsByCategory(category.ID)
+		assert.NoError(t, err)
+		assert.Len(t, retrievedKeywords, 1)
+
+		// Delete the keyword
+		err = f.DeleteKeyword(keyword.ID)
+		assert.NoError(t, err)
+
+		// Verify the keyword was deleted
+		retrievedKeywords, err = f.KeywordsByCategory(category.ID)
+		assert.NoError(t, err)
+		assert.Empty(t, retrievedKeywords)
+	})
+
+	t.Run("Delete Non-Existent Keyword", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		// Attempt to delete a keyword that does not exist
+		nonExistentKeywordID := uint8(99)
+		err = f.DeleteKeyword(nonExistentKeywordID)
+		assert.ErrorIs(t, err, ErrKeywordNotFound)
+	})
+
+	t.Run("Delete Keyword Associated with User", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		// Insert a category
+		categoryName := "CategoryInUse"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		// Insert a keyword
+		keywordName := "KeywordInUse"
+		keyword, err := f.CreateKeyword(keywordName, category.ID)
+		assert.NoError(t, err)
+
+		// Create a user and associate it with the keyword
+		u := User{
+			IdentScreenName: NewIdentScreenName("testuser"),
+		}
+		err = f.InsertUser(u)
+		assert.NoError(t, err)
+
+		err = f.SetKeywords(u.IdentScreenName, [5]string{keywordName})
+		assert.NoError(t, err)
+
+		// Attempt to delete the keyword and expect an ErrKeywordInUse
+		err = f.DeleteKeyword(keyword.ID)
+		assert.ErrorIs(t, err, ErrKeywordInUse)
+	})
+}
+
+func TestSQLiteUserStore_InterestList(t *testing.T) {
+	t.Run("Full list", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		tech, err := f.CreateCategory("Technology")
+		assert.NoError(t, err)
+		music, err := f.CreateCategory("Music")
+		assert.NoError(t, err)
+		sports, err := f.CreateCategory("Sports")
+		assert.NoError(t, err)
+
+		_, err = f.CreateKeyword("Rock", music.ID)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Soccer", sports.ID)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Cybersecurity", tech.ID)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Zoology", 0)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Jazz", music.ID)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Animals", 0)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Basketball", sports.ID)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Artificial Intelligence", tech.ID)
+		assert.NoError(t, err)
+		_, err = f.CreateKeyword("Tennis", sports.ID)
+		assert.NoError(t, err)
+
+		expect := []wire.ODirKeywordListItem{
+			{
+				ID:   0,
+				Name: "Animals",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   2,
+				Name: "Music",
+				Type: wire.ODirKeywordCategory,
+			},
+			{
+				ID:   2,
+				Name: "Jazz",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   2,
+				Name: "Rock",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   3,
+				Name: "Sports",
+				Type: wire.ODirKeywordCategory,
+			},
+			{
+				ID:   3,
+				Name: "Basketball",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   3,
+				Name: "Soccer",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   3,
+				Name: "Tennis",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   1,
+				Name: "Technology",
+				Type: wire.ODirKeywordCategory,
+			},
+			{
+				ID:   1,
+				Name: "Artificial Intelligence",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   1,
+				Name: "Cybersecurity",
+				Type: wire.ODirKeyword,
+			},
+			{
+				ID:   0,
+				Name: "Zoology",
+				Type: wire.ODirKeyword,
+			},
+		}
+
+		actual, err := f.InterestList()
+		assert.NoError(t, err)
+		assert.Equal(t, expect, actual)
+	})
+
+	t.Run("Empty list list", func(t *testing.T) {
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		actual, err := f.InterestList()
+		assert.NoError(t, err)
+		assert.Empty(t, actual)
+	})
+}
+
+func TestSQLiteUserStore_KeywordsByCategory(t *testing.T) {
+	t.Run("Category Does Not Exist", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		// Create a test category
+		categoryName := "TestCategory"
+		category, err := f.CreateCategory(categoryName)
+		assert.NoError(t, err)
+
+		keywords, err := f.KeywordsByCategory(category.ID + 1)
+		assert.Empty(t, keywords)
+		assert.ErrorIs(t, err, ErrKeywordCategoryNotFound)
+	})
+}

+ 70 - 0
wire/snacs.go

@@ -979,6 +979,76 @@ type SNAC_0x0E_0x06_ChatChannelMsgToClient struct {
 	TLVRestBlock
 }
 
+//
+// 0x0F: ODir
+//
+
+const (
+	ODirErr              uint16 = 0x0001
+	ODirInfoQuery        uint16 = 0x0002
+	ODirInfoReply        uint16 = 0x0003
+	ODirKeywordListQuery uint16 = 0x0004
+	ODirKeywordListReply uint16 = 0x0005
+
+	ODirTLVFirstName    uint16 = 0x0001 // The first name of the individual being searched.
+	ODirTLVLastName     uint16 = 0x0002 // The last name of the individual being searched.
+	ODirTLVMiddleName   uint16 = 0x0003 // The middle name of the individual being searched.
+	ODirTLVMaidenName   uint16 = 0x0004 // The maiden name of the individual being searched.
+	ODirTLVEmailAddress uint16 = 0x0005 // The email address you're searching for.
+	ODirTLVCountry      uint16 = 0x0006 // The country where the individual resides.
+	ODirTLVState        uint16 = 0x0007 // The state where the individual resides.
+	ODirTLVCity         uint16 = 0x0008 // The city where the individual resides.
+	ODirTLVScreenName   uint16 = 0x0009 // The screen name of the individual being searched.
+	ODirTLVSearchType   uint16 = 0x000a // Likely denotes the search type: 0x0000 for "name and other criteria" search, 0x0001 for "email address" or "interest" search.
+	ODirTLVInterest     uint16 = 0x000b // The interest or hobby of the individual being searched.
+	ODirTLVNickName     uint16 = 0x000c // The nickname of the individual being searched.
+	ODirTLVZIP          uint16 = 0x000d // The ZIP code where the individual resides.
+	ODirTLVRegion       uint16 = 0x001c // Encodes region information, possibly as 8 bytes in "us-ascii."
+	ODirTLVAddress      uint16 = 0x0021 // The street address where the individual resides.
+
+	ODirKeywordCategory uint8 = 0x01
+	ODirKeyword         uint8 = 0x02
+
+	ODirSearchByNameAndAddress  uint16 = 0x0000
+	ODirSearchByEmailOrInterest uint16 = 0x0001
+
+	ODirSearchResponseUnavailable1   uint16 = 0x01 // Search is unavailable
+	ODirSearchResponseUnavailable2   uint16 = 0x02 // Search is unavailable (same as above)
+	ODirSearchResponseTooManyResults uint16 = 0x03 // Too many results returned, narrow search
+	ODirSearchResponseNameMissing    uint16 = 0x04 // Missing first or last name
+	ODirSearchResponseOK             uint16 = 0x05 // Successful search
+)
+
+type SNAC_0x0F_0x02_InfoQuery struct {
+	TLVRestBlock
+}
+
+type SNAC_0x0F_0x03_InfoReply struct {
+	Status  uint16
+	Unknown uint16
+	Results struct {
+		List []TLVBlock `oscar:"count_prefix=uint16"`
+	} `oscar:"count_prefix=uint16"`
+}
+
+type SNAC_0x0F_0x04_KeywordListQuery struct{}
+
+type SNAC_0x0F_0x04_KeywordListReply struct {
+	Status    uint16
+	Interests []ODirKeywordListItem `oscar:"count_prefix=uint16"`
+}
+
+type ODirKeywordListItem struct {
+	// Type is the item type (parent category = 1, keyword = 2).
+	Type uint8
+	// ID is the ID of the keyword or category. If item type is category, then
+	// it's the category ID. If item type is keyword, then it's the parent
+	// category ID. If it's a top-level keyword, value is 0.
+	ID uint8
+	// Name is the keyword or category name.
+	Name string `oscar:"len_prefix=uint16"`
+}
+
 //
 // 0x10: BART
 //

+ 7 - 0
wire/snacs_string.go

@@ -292,6 +292,13 @@ var subGroupName = map[uint16]map[uint16]string{
 		ICQDBQuery: "ICQDBQuery",
 		ICQDBReply: "ICQDBReply",
 	},
+	ODir: {
+		ODirErr:              "ODirErr",
+		ODirInfoQuery:        "ODirInfoQuery",
+		ODirInfoReply:        "ODirInfoReply",
+		ODirKeywordListQuery: "ODirKeywordListQuery",
+		ODirKeywordListReply: "ODirKeywordListReply",
+	},
 }
 
 // SubGroupName gets the string name of a subgroup within a food group. It

+ 10 - 0
wire/tlv.go

@@ -83,6 +83,16 @@ func (s *TLVList) AppendList(tlvs []TLV) {
 	*s = append(*s, tlvs...)
 }
 
+// HasTag indicates if a TLV list has a tag.
+func (s *TLVList) HasTag(tag uint16) bool {
+	for _, tlv := range *s {
+		if tag == tlv.Tag {
+			return true
+		}
+	}
+	return false
+}
+
 // String retrieves the string value associated with the specified tag from the
 // TLVList.
 //

+ 20 - 0
wire/tlv_test.go

@@ -30,6 +30,26 @@ func TestTLVList_Append(t *testing.T) {
 	assert.Equal(t, want, have)
 }
 
+func TestTLVList_HasTag(t *testing.T) {
+	list := TLVList{
+		{
+			Tag:   0,
+			Value: []byte(`0`),
+		},
+		{
+			Tag:   1,
+			Value: []byte(`1`),
+		},
+		{
+			Tag:   2,
+			Value: []byte(`2`),
+		},
+	}
+
+	assert.True(t, list.HasTag(0))
+	assert.False(t, list.HasTag(3))
+}
+
 func TestTLVList_AppendList(t *testing.T) {
 	want := TLVList{
 		{