Просмотр исходного кода

issue #124: add is_bot user flag to mgmt api

Mike 1 год назад
Родитель
Сommit
105442755c

+ 19 - 5
api.yml

@@ -30,6 +30,10 @@ paths:
                     suspended_status:
                       type: string
                       description: User's suspended status
+                    is_bot:
+                      type: boolean
+                      nullable: true
+                      description: Indicates whether the user is a bot.
     post:
       summary: Create a new user
       description: Create a new AIM or ICQ user account.
@@ -115,6 +119,10 @@ paths:
                   suspended_status:
                     type: string
                     description: User's suspended status
+                  is_bot:
+                    type: boolean
+                    nullable: true
+                    description: Indicates whether the user is a bot.
         '404':
           description: User not found.
     patch:
@@ -144,11 +152,17 @@ paths:
               type: object
               properties:
                 suspended_status:
-                    type: string
-                    nullable: true
-                    enum: [deleted, expired, suspended, suspended_age]
-                    description: The suspended status of the account
-
+                  type: string
+                  nullable: true
+                  enum: [ deleted, expired, suspended, suspended_age ]
+                  description: The suspended status of the account
+                is_bot:
+                  type: boolean
+                  nullable: true
+                  description: >
+                    Indicates whether the account is for a bot. If enabled, client requests are rate limited at
+                    the most lenient tier in order to ensure that simultaneous interactions with multiple users do not
+                    kick the bot from the server. Make sure you trust the bot and bot owner before enabling this flag.
   /user/{screenname}/icon:
     get:
       summary: Get AIM buddy icon for a screen name

+ 11 - 1
server/http/helpers_test.go

@@ -4,9 +4,10 @@ import (
 	"context"
 	"net/mail"
 
+	"github.com/stretchr/testify/mock"
+
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
-	"github.com/stretchr/testify/mock"
 )
 
 type mockParams struct {
@@ -28,6 +29,7 @@ type accountManagerParams struct {
 	RegStatusParams
 	ConfirmStatusParams
 	updateSuspendedStatusParams
+	setBotStatusParams
 }
 
 // EmailAddressParams is the list of parameters passed at the mock
@@ -62,6 +64,14 @@ type updateSuspendedStatusParams []struct {
 	err             error
 }
 
+// setBotStatusParams is the list of parameters passed at the mock
+// accountManager.SetBotStatus call site
+type setBotStatusParams []struct {
+	isBot      bool
+	screenName state.IdentScreenName
+	err        error
+}
+
 // bartRetrieverParams is a helper struct that contains mock parameters for
 // BuddyIconRetriever methods
 type bartRetrieverParams struct {

+ 12 - 2
server/http/mgmt_api.go

@@ -320,6 +320,7 @@ func getUserHandler(w http.ResponseWriter, r *http.Request, userManager UserMana
 			ScreenName:      u.DisplayScreenName.String(),
 			IsICQ:           u.IsICQ,
 			SuspendedStatus: suspendedStatus,
+			IsBot:           u.IsBot,
 		}
 	}
 
@@ -684,6 +685,7 @@ func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager U
 		Profile:         profile,
 		IsICQ:           user.IsICQ,
 		SuspendedStatus: suspendedStatusText,
+		IsBot:           user.IsBot,
 	}
 
 	if err := json.NewEncoder(w).Encode(out); err != nil {
@@ -729,8 +731,7 @@ func patchUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager
 				return
 			}
 			if suspendedStatus != user.SuspendedStatus {
-				err := a.UpdateSuspendedStatus(r.Context(), suspendedStatus, user.IdentScreenName)
-				if err != nil {
+				if err := a.UpdateSuspendedStatus(r.Context(), suspendedStatus, user.IdentScreenName); err != nil {
 					logger.Error("error in PATCH /user/{screenname}/account", "err", err.Error())
 					http.Error(w, "internal server error", http.StatusInternalServerError)
 					return
@@ -743,6 +744,15 @@ func patchUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager
 		}
 	}
 
+	if input.IsBot != nil && user.IsBot != *input.IsBot {
+		if err := a.SetBotStatus(r.Context(), *input.IsBot, user.IdentScreenName); err != nil {
+			logger.Error("error in PATCH /user/{screenname}/account", "err", err.Error())
+			http.Error(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
+		modifiedUser = true
+	}
+
 	if !modifiedUser {
 		w.WriteHeader(http.StatusNotModified)
 		return

+ 141 - 3
server/http/mgmt_api_test.go

@@ -276,7 +276,7 @@ func TestUserAccountHandler_GET(t *testing.T) {
 		{
 			name:              "valid aim account",
 			requestScreenName: state.NewIdentScreenName("userA"),
-			want:              `{"id":"usera","screen_name":"userA","profile":"My Profile Text","email_address":"\u003cuserA@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":""}`,
+			want:              `{"id":"usera","screen_name":"userA","profile":"My Profile Text","email_address":"\u003cuserA@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":"","is_bot":false}`,
 			statusCode:        http.StatusOK,
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
@@ -323,10 +323,61 @@ func TestUserAccountHandler_GET(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:              "valid aim bot account",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			want:              `{"id":"usera","screen_name":"userA","profile":"My Profile Text","email_address":"\u003cuserA@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":"","is_bot":true}`,
+			statusCode:        http.StatusOK,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   0x0,
+								IsBot:             true,
+							},
+						},
+					},
+				},
+				accountManagerParams: accountManagerParams{
+					EmailAddressParams: EmailAddressParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &mail.Address{
+								Address: "userA@aol.com",
+							},
+						},
+					},
+					RegStatusParams: RegStatusParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     uint16(0x02),
+						},
+					},
+					ConfirmStatusParams: ConfirmStatusParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     true,
+						},
+					},
+				},
+				profileRetrieverParams: profileRetrieverParams{
+					retrieveProfileParams: retrieveProfileParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     "My Profile Text",
+						},
+					},
+				},
+			},
+		},
 		{
 			name:              "suspended aim account",
 			requestScreenName: state.NewIdentScreenName("userB"),
-			want:              `{"id":"userb","screen_name":"userB","profile":"My Profile Text","email_address":"\u003cuserB@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":"suspended"}`,
+			want:              `{"id":"userb","screen_name":"userB","profile":"My Profile Text","email_address":"\u003cuserB@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":"suspended","is_bot":false}`,
 			statusCode:        http.StatusOK,
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
@@ -546,6 +597,87 @@ func TestUserAccountHandler_PATCH(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:              "setting bot flag (before: false, after: true)",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNoContent,
+			body:              `{"is_bot":true}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   0x0,
+								IsBot:             false,
+							},
+						},
+					},
+				},
+				accountManagerParams: accountManagerParams{
+					setBotStatusParams: setBotStatusParams{
+						{
+							isBot:      true,
+							screenName: state.NewIdentScreenName("userA"),
+							err:        nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "setting bot flag (before: true, after: false)",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNoContent,
+			body:              `{"is_bot":false}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   0x0,
+								IsBot:             true,
+							},
+						},
+					},
+				},
+				accountManagerParams: accountManagerParams{
+					setBotStatusParams: setBotStatusParams{
+						{
+							isBot:      false,
+							screenName: state.NewIdentScreenName("userA"),
+							err:        nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "setting bot flag (before: true, after: true)",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNotModified,
+			body:              `{"is_bot":true}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   0x0,
+								IsBot:             true,
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range tt {
@@ -567,6 +699,11 @@ func TestUserAccountHandler_PATCH(t *testing.T) {
 					UpdateSuspendedStatus(matchContext(), params.suspendedStatus, params.screenName).
 					Return(params.err)
 			}
+			for _, params := range tc.mockParams.accountManagerParams.setBotStatusParams {
+				accountManager.EXPECT().
+					SetBotStatus(matchContext(), params.isBot, params.screenName).
+					Return(params.err)
+			}
 
 			patchUserAccountHandler(responseRecorder, request, userManager, accountManager, slog.Default())
 
@@ -871,7 +1008,7 @@ func TestUserHandler_GET(t *testing.T) {
 		},
 		{
 			name:       "user store containing 3 users",
-			want:       `[{"id":"usera","screen_name":"userA","is_icq":false,"suspended_status":""},{"id":"userb","screen_name":"userB","is_icq":false,"suspended_status":""},{"id":"100003","screen_name":"100003","is_icq":true,"suspended_status":""}]`,
+			want:       `[{"id":"usera","screen_name":"userA","is_icq":false,"suspended_status":"","is_bot":false},{"id":"userb","screen_name":"userB","is_icq":false,"suspended_status":"","is_bot":true},{"id":"100003","screen_name":"100003","is_icq":true,"suspended_status":"","is_bot":false}]`,
 			statusCode: http.StatusOK,
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
@@ -885,6 +1022,7 @@ func TestUserHandler_GET(t *testing.T) {
 								{
 									DisplayScreenName: "userB",
 									IdentScreenName:   state.NewIdentScreenName("userB"),
+									IsBot:             true,
 								},
 								{
 									DisplayScreenName: "100003",

+ 48 - 0
server/http/mock_account_manager_test.go

@@ -197,6 +197,54 @@ func (_c *mockAccountManager_RegStatus_Call) RunAndReturn(run func(context.Conte
 	return _c
 }
 
+// SetBotStatus provides a mock function with given fields: ctx, isBot, screenName
+func (_m *mockAccountManager) SetBotStatus(ctx context.Context, isBot bool, screenName state.IdentScreenName) error {
+	ret := _m.Called(ctx, isBot, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetBotStatus")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, bool, state.IdentScreenName) error); ok {
+		r0 = rf(ctx, isBot, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockAccountManager_SetBotStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetBotStatus'
+type mockAccountManager_SetBotStatus_Call struct {
+	*mock.Call
+}
+
+// SetBotStatus is a helper method to define mock.On call
+//   - ctx context.Context
+//   - isBot bool
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) SetBotStatus(ctx interface{}, isBot interface{}, screenName interface{}) *mockAccountManager_SetBotStatus_Call {
+	return &mockAccountManager_SetBotStatus_Call{Call: _e.mock.On("SetBotStatus", ctx, isBot, screenName)}
+}
+
+func (_c *mockAccountManager_SetBotStatus_Call) Run(run func(ctx context.Context, isBot bool, screenName state.IdentScreenName)) *mockAccountManager_SetBotStatus_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(bool), args[2].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_SetBotStatus_Call) Return(_a0 error) *mockAccountManager_SetBotStatus_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockAccountManager_SetBotStatus_Call) RunAndReturn(run func(context.Context, bool, state.IdentScreenName) error) *mockAccountManager_SetBotStatus_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // UpdateSuspendedStatus provides a mock function with given fields: ctx, suspendedStatus, screenName
 func (_m *mockAccountManager) UpdateSuspendedStatus(ctx context.Context, suspendedStatus uint16, screenName state.IdentScreenName) error {
 	ret := _m.Called(ctx, suspendedStatus, screenName)

+ 6 - 0
server/http/types.go

@@ -27,6 +27,9 @@ type AccountManager interface {
 
 	// UpdateSuspendedStatus updates the suspension status of a user account.
 	UpdateSuspendedStatus(ctx context.Context, suspendedStatus uint16, screenName state.IdentScreenName) error
+
+	// SetBotStatus updates the flag that indicates whether the user is a bot.
+	SetBotStatus(ctx context.Context, isBot bool, screenName state.IdentScreenName) error
 }
 
 // BuddyIconRetriever defines a method for retrieving a buddy icon image by its hash.
@@ -141,6 +144,7 @@ type userHandle struct {
 	ScreenName      string `json:"screen_name"`
 	IsICQ           bool   `json:"is_icq"`
 	SuspendedStatus string `json:"suspended_status"`
+	IsBot           bool   `json:"is_bot"`
 }
 
 type aimChatUserHandle struct {
@@ -157,10 +161,12 @@ type userAccountHandle struct {
 	Confirmed       bool   `json:"confirmed"`
 	IsICQ           bool   `json:"is_icq"`
 	SuspendedStatus string `json:"suspended_status"`
+	IsBot           bool   `json:"is_bot"`
 }
 
 type userAccountPatch struct {
 	SuspendedStatusText *string `json:"suspended_status"`
+	IsBot               *bool   `json:"is_bot"`
 }
 
 type sessionHandle struct {

+ 262 - 0
state/migrations/0013_is_bot.down.sql

@@ -0,0 +1,262 @@
+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 '',
+    tocConfig                        TEXT         NOT NULL DEFAULT '',
+    suspendedStatus                  INT          NOT NULL DEFAULT 0,
+
+    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,
+                   tocConfig,
+                   suspendedStatus)
+SELECT 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,
+       tocConfig,
+       suspendedStatus
+FROM users_old;
+
+DROP TABLE users_old;

+ 2 - 0
state/migrations/0013_is_bot.up.sql

@@ -0,0 +1,2 @@
+ALTER TABLE users
+    ADD COLUMN isBot BOOLEAN NOT NULL DEFAULT false;

+ 2 - 0
state/user.go

@@ -203,6 +203,8 @@ type User struct {
 	// TOCConfig is the user's saved server-side info (buddy list, etc) for
 	// on the TOC service.
 	TOCConfig string
+	// IsBot indicates whether the user is a bot.
+	IsBot bool
 }
 
 // AIMNameAndAddr holds name and address AIM directory information.

+ 19 - 5
state/user_store.go

@@ -97,7 +97,7 @@ func (f SQLiteUserStore) runMigrations() error {
 }
 
 func (f SQLiteUserStore) AllUsers(ctx context.Context) ([]User, error) {
-	q := `SELECT identScreenName, displayScreenName, isICQ FROM users`
+	q := `SELECT identScreenName, displayScreenName, isICQ, isBot FROM users`
 	rows, err := f.db.QueryContext(ctx, q)
 	if err != nil {
 		return nil, err
@@ -107,14 +107,15 @@ func (f SQLiteUserStore) AllUsers(ctx context.Context) ([]User, error) {
 	var users []User
 	for rows.Next() {
 		var identSN, displaySN string
-		var isICQ bool
-		if err := rows.Scan(&identSN, &displaySN, &isICQ); err != nil {
+		var isICQ, isBot bool
+		if err := rows.Scan(&identSN, &displaySN, &isICQ, &isBot); err != nil {
 			return nil, err
 		}
 		users = append(users, User{
 			IdentScreenName:   NewIdentScreenName(identSN),
 			DisplayScreenName: DisplayScreenName(displaySN),
 			IsICQ:             isICQ,
+			IsBot:             isBot,
 		})
 	}
 
@@ -341,6 +342,7 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			confirmStatus,
 			regStatus,
 			suspendedStatus,
+			isBot,
 			isICQ,
 			icq_affiliations_currentCode1,
 			icq_affiliations_currentCode2,
@@ -433,6 +435,7 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			&u.ConfirmStatus,
 			&u.RegStatus,
 			&u.SuspendedStatus,
+			&u.IsBot,
 			&u.IsICQ,
 			&u.ICQAffiliations.CurrentCode1,
 			&u.ICQAffiliations.CurrentCode2,
@@ -520,8 +523,8 @@ func (f SQLiteUserStore) InsertUser(ctx context.Context, u User) error {
 		return errors.New("inserting user with UIN and isICQ=false")
 	}
 	q := `
-		INSERT INTO users (identScreenName, displayScreenName, authKey, weakMD5Pass, strongMD5Pass, isICQ)
-		VALUES (?, ?, ?, ?, ?, ?)
+		INSERT INTO users (identScreenName, displayScreenName, authKey, weakMD5Pass, strongMD5Pass, isICQ, isBot)
+		VALUES (?, ?, ?, ?, ?, ?, ?)
 		ON CONFLICT (identScreenName) DO NOTHING
 	`
 	result, err := f.db.ExecContext(ctx,
@@ -532,6 +535,7 @@ func (f SQLiteUserStore) InsertUser(ctx context.Context, u User) error {
 		u.WeakMD5Pass,
 		u.StrongMD5Pass,
 		u.IsICQ,
+		u.IsBot,
 	)
 	if err != nil {
 		return err
@@ -1231,6 +1235,16 @@ func (f SQLiteUserStore) UpdateSuspendedStatus(ctx context.Context, suspendedSta
 	return err
 }
 
+func (f SQLiteUserStore) SetBotStatus(ctx context.Context, isBot bool, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET isBot = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.ExecContext(ctx, q, isBot, screenName.String())
+	return err
+}
+
 func (f SQLiteUserStore) SetWorkInfo(ctx context.Context, name IdentScreenName, data ICQWorkInfo) error {
 	q := `
 		UPDATE users SET 

+ 39 - 0
state/user_store_test.go

@@ -371,6 +371,7 @@ func TestSQLiteUserStore_Users(t *testing.T) {
 		{
 			IdentScreenName:   NewIdentScreenName("userC"),
 			DisplayScreenName: "userC",
+			IsBot:             true,
 		},
 		{
 			IdentScreenName:   NewIdentScreenName("100003"),
@@ -3238,5 +3239,43 @@ func TestSQLiteUserStore_UpdateSuspendedStatus(t *testing.T) {
 	assert.NoError(t, err)
 
 	assert.Equal(t, user.SuspendedStatus, wire.LoginErrSuspendedAccountAge)
+}
+
+func TestSQLiteUserStore_SetBotStatus(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
 
+	screenName := NewIdentScreenName("userA")
+
+	insertedUser := &User{
+		IdentScreenName:   screenName,
+		DisplayScreenName: DisplayScreenName("usera"),
+		AuthKey:           "theauthkey",
+		StrongMD5Pass:     []byte("thepasshash"),
+		IsBot:             false,
+	}
+	err = f.InsertUser(context.Background(), *insertedUser)
+	assert.NoError(t, err)
+
+	user, err := f.User(context.Background(), screenName)
+	assert.NoError(t, err)
+	assert.False(t, user.IsBot)
+
+	err = f.SetBotStatus(context.Background(), true, screenName)
+	assert.NoError(t, err)
+
+	user, err = f.User(context.Background(), screenName)
+	assert.NoError(t, err)
+	assert.True(t, user.IsBot)
+
+	err = f.SetBotStatus(context.Background(), false, screenName)
+	assert.NoError(t, err)
+
+	user, err = f.User(context.Background(), screenName)
+	assert.NoError(t, err)
+	assert.False(t, user.IsBot)
 }