fix(shangwutong): harden classification operations

This commit is contained in:
2026-09-11 23:09:02 +08:00
parent 31f157a4f5
commit 9b39a574bd
16 changed files with 904 additions and 68 deletions
@@ -389,7 +389,15 @@ func (h *ShangwutongConnectorHandler) UpdateConversationClassification(c *gin.Co
return
}
var conversation model.Conversation
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", uint(conversationID), uint(accountID)).First(&conversation).Error; err != nil {
lookup := h.db.WithContext(c.Request.Context()).Where(
"id = ? AND account_id = ? AND (display_id IS NULL OR display_id = 0)", uint(conversationID), uint(accountID),
).First(&conversation)
if errors.Is(lookup.Error, gorm.ErrRecordNotFound) {
lookup = h.db.WithContext(c.Request.Context()).Where(
"display_id = ? AND account_id = ?", uint(conversationID), uint(accountID),
).First(&conversation)
}
if lookup.Error != nil {
h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false)
return
}
@@ -21,6 +21,7 @@ import (
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/internal/worker"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -156,6 +157,32 @@ func TestShangwutongConnectorContactMetadataIsScopedAndIdempotent(t *testing.T)
require.Contains(t, string(job.Payload), `"contact_name":"Renamed"`)
}
func TestShangwutongClassificationUpdateAcceptsConversationDisplayID(t *testing.T) {
router, db, _, inbox, _ := setupShangwutongConnectorAPI(t)
contact := &model.Contact{AccountID: inbox.AccountID, Name: "Visitor"}
require.NoError(t, db.Create(contact).Error)
conversation := &model.Conversation{
AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open",
DisplayID: uintPointer(9001),
}
require.NoError(t, db.Create(conversation).Error)
require.NoError(t, db.Create(&model.ContactInbox{
ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor", ChannelMetadata: datatypes.JSON([]byte(`{"cid":"cid-1"}`)),
}).Error)
require.NoError(t, db.Create(&model.ShangwutongClassificationCache{
InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[{"id":"kind-1","name":"Normal"}]`)),
CustomerColorKinds: datatypes.JSON([]byte(`[]`)), SyncStatus: "succeeded",
}).Error)
path := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/shangwutong-classifications", inbox.AccountID, *conversation.DisplayID)
response := connectorRequest(t, router, "", http.MethodPatch, path, map[string]string{"chat_kind_id": "kind-1"})
require.Equal(t, http.StatusAccepted, response.Code, response.Body.String())
var job model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", service.TaskTypeShangwutongWebhookDelivery).Order("id DESC").First(&job).Error)
require.Contains(t, string(job.Payload), fmt.Sprintf(`"conversation_id":%d`, conversation.ID))
}
func uintPointer(value uint) *uint { return &value }
func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, *model.Inbox, *model.Inbox) {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -165,7 +192,7 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string,
&model.Account{}, &model.Inbox{}, &model.ChannelShangwutongConfig{}, &channelmodel.ChannelAPI{},
&model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}, &model.Contact{}, &model.Conversation{},
&model.ContactInbox{},
&model.Message{}, &model.Attachment{}, &model.BackgroundJob{},
&model.Message{}, &model.Attachment{}, &model.BackgroundJob{}, &model.ShangwutongClassificationCache{},
))
active := true
app := &model.PlatformApp{Name: "SWT Connector", Type: "integration", Status: "active", Active: &active, Config: json.RawMessage(`{"connector":"shangwutong"}`)}
@@ -198,7 +225,8 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string,
inboxes = append(inboxes, inbox)
}
messageSvc := service.NewMessageService(repository.NewMessageRepo(db), channel.NewDispatcher(), nil)
handler := NewShangwutongConnectorHandler(db, messageSvc)
workers := worker.NewWorkerPool(db)
handler := NewShangwutongConnectorHandler(db, messageSvc, workers)
router := gin.New()
group := router.Group("/api/v1/connector/shangwutong")
group.Use(middleware.ConnectorServiceAuth(db))
@@ -207,6 +235,7 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string,
group.PUT("/inboxes/:inbox_id/status", handler.UpdateInboxStatus)
group.PUT("/inboxes/:inbox_id/messages/:message_id/status", handler.UpdateMessageStatus)
group.PATCH("/inboxes/:inbox_id/contacts/:source_id", handler.UpdateContactMetadata)
router.PATCH("/api/v1/accounts/:account_id/conversations/:conversation_id/shangwutong-classifications", handler.UpdateConversationClassification)
return router, db, token, inboxes[0], inboxes[1]
}
+21 -14
View File
@@ -131,20 +131,27 @@ type OutboundMessage struct {
}
type OutboundOperation struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
SwtSid string `json:"swt_sid"`
EventID string `json:"event_id"`
Operation string `json:"operation"`
Payload string `json:"payload"`
OccurredAt time.Time `json:"occurred_at"`
DeliveryStatus string `json:"delivery_status"`
ClaimedAt *time.Time `json:"claimed_at"`
Attempts int64 `json:"attempts"`
NextAttemptAt *time.Time `json:"next_attempt_at"`
LastError *string `json:"last_error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
SwtSid string `json:"swt_sid"`
EventID string `json:"event_id"`
Operation string `json:"operation"`
Payload string `json:"payload"`
OccurredAt time.Time `json:"occurred_at"`
DeliveryStatus string `json:"delivery_status"`
ClaimedAt *time.Time `json:"claimed_at"`
Attempts int64 `json:"attempts"`
NextAttemptAt *time.Time `json:"next_attempt_at"`
LastError *string `json:"last_error"`
ResultSyncStatus string `json:"result_sync_status"`
ResultSyncAttempts int64 `json:"result_sync_attempts"`
ResultSyncNextAt *time.Time `json:"result_sync_next_at"`
ResultReportedAt *time.Time `json:"result_reported_at"`
ResultStatus *string `json:"result_status"`
ResultErrorCode *string `json:"result_error_code"`
ResultErrorMessage *string `json:"result_error_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OutboundPart struct {
@@ -45,11 +45,17 @@ WHERE id = (
(julianday(earlier_message.occurred_at) = julianday(candidate.occurred_at) AND earlier_message.event_id < candidate.event_id)
)
)
AND NOT EXISTS (
SELECT 1 FROM outbound_operations AS earlier_result
WHERE earlier_result.account_id = candidate.account_id
AND earlier_result.id < candidate.id
AND earlier_result.result_sync_status IN ('pending', 'syncing')
)
ORDER BY candidate.id
LIMIT 1
)
AND delivery_status = 'pending'
RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, created_at, updated_at
RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at
`
func (q *Queries) ClaimOutboundOperation(ctx context.Context) (*OutboundOperation, error) {
@@ -68,6 +74,68 @@ func (q *Queries) ClaimOutboundOperation(ctx context.Context) (*OutboundOperatio
&i.Attempts,
&i.NextAttemptAt,
&i.LastError,
&i.ResultSyncStatus,
&i.ResultSyncAttempts,
&i.ResultSyncNextAt,
&i.ResultReportedAt,
&i.ResultStatus,
&i.ResultErrorCode,
&i.ResultErrorMessage,
&i.CreatedAt,
&i.UpdatedAt,
)
return &i, err
}
const claimOutboundOperationResult = `-- name: ClaimOutboundOperationResult :one
UPDATE outbound_operations SET
result_sync_status = 'syncing',
result_sync_attempts = result_sync_attempts + 1,
result_sync_next_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = (
SELECT candidate.id
FROM outbound_operations AS candidate
WHERE candidate.operation IN ('set_chat_kind', 'set_customer_color')
AND candidate.delivery_status IN ('delivered', 'uncertain', 'failed')
AND candidate.result_sync_status = 'pending'
AND (candidate.result_sync_next_at IS NULL OR julianday(candidate.result_sync_next_at) IS NULL OR julianday(candidate.result_sync_next_at) <= julianday('now'))
AND NOT EXISTS (
SELECT 1 FROM outbound_operations AS earlier
WHERE earlier.account_id = candidate.account_id
AND earlier.id < candidate.id
AND earlier.result_sync_status IN ('pending', 'syncing')
)
ORDER BY candidate.id
LIMIT 1
)
AND result_sync_status = 'pending'
RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at
`
func (q *Queries) ClaimOutboundOperationResult(ctx context.Context) (*OutboundOperation, error) {
row := q.db.QueryRowContext(ctx, claimOutboundOperationResult)
var i OutboundOperation
err := row.Scan(
&i.ID,
&i.AccountID,
&i.SwtSid,
&i.EventID,
&i.Operation,
&i.Payload,
&i.OccurredAt,
&i.DeliveryStatus,
&i.ClaimedAt,
&i.Attempts,
&i.NextAttemptAt,
&i.LastError,
&i.ResultSyncStatus,
&i.ResultSyncAttempts,
&i.ResultSyncNextAt,
&i.ResultReportedAt,
&i.ResultStatus,
&i.ResultErrorCode,
&i.ResultErrorMessage,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -79,6 +147,23 @@ UPDATE outbound_operations SET
delivery_status = 'delivered',
claimed_at = NULL,
last_error = NULL,
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'succeeded'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status = 'delivering'
`
@@ -88,13 +173,46 @@ func (q *Queries) CompleteOutboundOperation(ctx context.Context, id int64) error
return err
}
const completeOutboundOperationResult = `-- name: CompleteOutboundOperationResult :exec
UPDATE outbound_operations SET
result_sync_status = 'synced',
result_reported_at = CURRENT_TIMESTAMP,
result_sync_next_at = NULL,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND result_sync_status = 'syncing'
`
func (q *Queries) CompleteOutboundOperationResult(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, completeOutboundOperationResult, id)
return err
}
const failExpiredUncertainOperations = `-- name: FailExpiredUncertainOperations :execrows
UPDATE outbound_operations SET
delivery_status = 'failed',
claimed_at = NULL,
last_error = 'operation result remained uncertain beyond the observation window',
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain_timeout'
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'operation result remained uncertain beyond the observation window'
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE delivery_status = 'uncertain'
AND result_sync_status <> 'syncing'
AND julianday(updated_at) <= julianday(?)
`
@@ -111,22 +229,65 @@ UPDATE outbound_operations SET
delivery_status = 'failed',
claimed_at = NULL,
last_error = ?,
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status IN ('delivering', 'uncertain')
`
type FailOutboundOperationParams struct {
LastError *string `json:"last_error"`
ResultErrorCode *string `json:"result_error_code"`
ResultErrorMessage *string `json:"result_error_message"`
ID int64 `json:"id"`
}
func (q *Queries) FailOutboundOperation(ctx context.Context, arg FailOutboundOperationParams) error {
_, err := q.db.ExecContext(ctx, failOutboundOperation,
arg.LastError,
arg.ResultErrorCode,
arg.ResultErrorMessage,
arg.ID,
)
return err
}
const failOutboundOperationResult = `-- name: FailOutboundOperationResult :exec
UPDATE outbound_operations SET
result_sync_status = 'failed',
result_sync_next_at = NULL,
last_error = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND result_sync_status = 'syncing'
`
type FailOutboundOperationResultParams struct {
LastError *string `json:"last_error"`
ID int64 `json:"id"`
}
func (q *Queries) FailOutboundOperation(ctx context.Context, arg FailOutboundOperationParams) error {
_, err := q.db.ExecContext(ctx, failOutboundOperation, arg.LastError, arg.ID)
func (q *Queries) FailOutboundOperationResult(ctx context.Context, arg FailOutboundOperationResultParams) error {
_, err := q.db.ExecContext(ctx, failOutboundOperationResult, arg.LastError, arg.ID)
return err
}
const getOutboundOperationByEventID = `-- name: GetOutboundOperationByEventID :one
SELECT id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, created_at, updated_at FROM outbound_operations
SELECT id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at FROM outbound_operations
WHERE event_id = ?
LIMIT 1
`
@@ -147,6 +308,13 @@ func (q *Queries) GetOutboundOperationByEventID(ctx context.Context, eventID str
&i.Attempts,
&i.NextAttemptAt,
&i.LastError,
&i.ResultSyncStatus,
&i.ResultSyncAttempts,
&i.ResultSyncNextAt,
&i.ResultReportedAt,
&i.ResultStatus,
&i.ResultErrorCode,
&i.ResultErrorMessage,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -158,7 +326,7 @@ INSERT INTO outbound_operations (
account_id, swt_sid, event_id, operation, payload, occurred_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, created_at, updated_at
RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at
`
type InsertOutboundOperationParams struct {
@@ -193,6 +361,13 @@ func (q *Queries) InsertOutboundOperation(ctx context.Context, arg InsertOutboun
&i.Attempts,
&i.NextAttemptAt,
&i.LastError,
&i.ResultSyncStatus,
&i.ResultSyncAttempts,
&i.ResultSyncNextAt,
&i.ResultReportedAt,
&i.ResultStatus,
&i.ResultErrorCode,
&i.ResultErrorMessage,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -204,25 +379,82 @@ UPDATE outbound_operations SET
delivery_status = 'uncertain',
claimed_at = NULL,
last_error = ?,
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status = 'delivering'
`
type MarkOutboundOperationUncertainParams struct {
LastError *string `json:"last_error"`
ID int64 `json:"id"`
LastError *string `json:"last_error"`
ResultErrorCode *string `json:"result_error_code"`
ResultErrorMessage *string `json:"result_error_message"`
ID int64 `json:"id"`
}
func (q *Queries) MarkOutboundOperationUncertain(ctx context.Context, arg MarkOutboundOperationUncertainParams) error {
_, err := q.db.ExecContext(ctx, markOutboundOperationUncertain, arg.LastError, arg.ID)
_, err := q.db.ExecContext(ctx, markOutboundOperationUncertain,
arg.LastError,
arg.ResultErrorCode,
arg.ResultErrorMessage,
arg.ID,
)
return err
}
const recoverOutboundOperationResults = `-- name: RecoverOutboundOperationResults :execrows
UPDATE outbound_operations SET
result_sync_status = 'pending',
result_sync_next_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE result_sync_status = 'syncing'
`
func (q *Queries) RecoverOutboundOperationResults(ctx context.Context) (int64, error) {
result, err := q.db.ExecContext(ctx, recoverOutboundOperationResults)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const recoverOutboundOperationsAsUncertain = `-- name: RecoverOutboundOperationsAsUncertain :execrows
UPDATE outbound_operations SET
delivery_status = 'uncertain',
claimed_at = NULL,
last_error = 'connector restarted while operation was delivering',
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector_restart_uncertain'
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector restarted while operation was delivering'
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE delivery_status = 'delivering'
`
@@ -255,3 +487,23 @@ func (q *Queries) RetryOutboundOperation(ctx context.Context, arg RetryOutboundO
_, err := q.db.ExecContext(ctx, retryOutboundOperation, arg.NextAttemptAt, arg.LastError, arg.ID)
return err
}
const retryOutboundOperationResult = `-- name: RetryOutboundOperationResult :exec
UPDATE outbound_operations SET
result_sync_status = 'pending',
result_sync_next_at = ?,
last_error = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND result_sync_status = 'syncing'
`
type RetryOutboundOperationResultParams struct {
ResultSyncNextAt *time.Time `json:"result_sync_next_at"`
LastError *string `json:"last_error"`
ID int64 `json:"id"`
}
func (q *Queries) RetryOutboundOperationResult(ctx context.Context, arg RetryOutboundOperationResultParams) error {
_, err := q.db.ExecContext(ctx, retryOutboundOperationResult, arg.ResultSyncNextAt, arg.LastError, arg.ID)
return err
}
@@ -0,0 +1,38 @@
CREATE TABLE outbound_operations_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
swt_sid TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
occurred_at DATETIME NOT NULL,
delivery_status TEXT NOT NULL DEFAULT 'pending',
claimed_at DATETIME,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at DATETIME,
last_error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(operation IN ('end_conversation', 'change_contact_name', 'accept_transfer', 'transfer_conversation')),
CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed'))
);
INSERT INTO outbound_operations_old (
id, account_id, swt_sid, event_id, operation, payload, occurred_at,
delivery_status, claimed_at, attempts, next_attempt_at, last_error,
created_at, updated_at
)
SELECT
id, account_id, swt_sid, event_id, operation, payload, occurred_at,
delivery_status, claimed_at, attempts, next_attempt_at, last_error,
created_at, updated_at
FROM outbound_operations;
DROP TABLE outbound_operations;
ALTER TABLE outbound_operations_old RENAME TO outbound_operations;
CREATE INDEX idx_outbound_operations_ready
ON outbound_operations(delivery_status, next_attempt_at, account_id, id);
CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account
ON outbound_operations(account_id)
WHERE delivery_status = 'delivering';
@@ -0,0 +1,48 @@
CREATE TABLE outbound_operations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
swt_sid TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
occurred_at DATETIME NOT NULL,
delivery_status TEXT NOT NULL DEFAULT 'pending',
claimed_at DATETIME,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at DATETIME,
last_error TEXT,
result_sync_status TEXT NOT NULL DEFAULT 'not_required',
result_sync_attempts INTEGER NOT NULL DEFAULT 0,
result_sync_next_at DATETIME,
result_reported_at DATETIME,
result_status TEXT,
result_error_code TEXT,
result_error_message TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(operation IN ('end_conversation', 'change_contact_name', 'accept_transfer', 'transfer_conversation', 'set_chat_kind', 'set_customer_color')),
CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')),
CHECK(result_sync_status IN ('not_required', 'pending', 'syncing', 'synced', 'failed'))
);
INSERT INTO outbound_operations_new (
id, account_id, swt_sid, event_id, operation, payload, occurred_at,
delivery_status, claimed_at, attempts, next_attempt_at, last_error,
created_at, updated_at
)
SELECT
id, account_id, swt_sid, event_id, operation, payload, occurred_at,
delivery_status, claimed_at, attempts, next_attempt_at, last_error,
created_at, updated_at
FROM outbound_operations;
DROP TABLE outbound_operations;
ALTER TABLE outbound_operations_new RENAME TO outbound_operations;
CREATE INDEX idx_outbound_operations_ready
ON outbound_operations(delivery_status, next_attempt_at, account_id, id);
CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account
ON outbound_operations(account_id)
WHERE delivery_status = 'delivering';
CREATE INDEX idx_outbound_operation_result_sync_ready
ON outbound_operations(result_sync_status, result_sync_next_at, account_id, id);
@@ -45,6 +45,12 @@ WHERE id = (
(julianday(earlier_message.occurred_at) = julianday(candidate.occurred_at) AND earlier_message.event_id < candidate.event_id)
)
)
AND NOT EXISTS (
SELECT 1 FROM outbound_operations AS earlier_result
WHERE earlier_result.account_id = candidate.account_id
AND earlier_result.id < candidate.id
AND earlier_result.result_sync_status IN ('pending', 'syncing')
)
ORDER BY candidate.id
LIMIT 1
)
@@ -56,6 +62,23 @@ UPDATE outbound_operations SET
delivery_status = 'delivered',
claimed_at = NULL,
last_error = NULL,
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'succeeded'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status = 'delivering';
@@ -68,11 +91,85 @@ UPDATE outbound_operations SET
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status = 'delivering';
-- name: ClaimOutboundOperationResult :one
UPDATE outbound_operations SET
result_sync_status = 'syncing',
result_sync_attempts = result_sync_attempts + 1,
result_sync_next_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = (
SELECT candidate.id
FROM outbound_operations AS candidate
WHERE candidate.operation IN ('set_chat_kind', 'set_customer_color')
AND candidate.delivery_status IN ('delivered', 'uncertain', 'failed')
AND candidate.result_sync_status = 'pending'
AND (candidate.result_sync_next_at IS NULL OR julianday(candidate.result_sync_next_at) IS NULL OR julianday(candidate.result_sync_next_at) <= julianday('now'))
AND NOT EXISTS (
SELECT 1 FROM outbound_operations AS earlier
WHERE earlier.account_id = candidate.account_id
AND earlier.id < candidate.id
AND earlier.result_sync_status IN ('pending', 'syncing')
)
ORDER BY candidate.id
LIMIT 1
)
AND result_sync_status = 'pending'
RETURNING *;
-- name: CompleteOutboundOperationResult :exec
UPDATE outbound_operations SET
result_sync_status = 'synced',
result_reported_at = CURRENT_TIMESTAMP,
result_sync_next_at = NULL,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND result_sync_status = 'syncing';
-- name: RetryOutboundOperationResult :exec
UPDATE outbound_operations SET
result_sync_status = 'pending',
result_sync_next_at = ?,
last_error = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND result_sync_status = 'syncing';
-- name: FailOutboundOperationResult :exec
UPDATE outbound_operations SET
result_sync_status = 'failed',
result_sync_next_at = NULL,
last_error = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND result_sync_status = 'syncing';
-- name: RecoverOutboundOperationResults :execrows
UPDATE outbound_operations SET
result_sync_status = 'pending',
result_sync_next_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE result_sync_status = 'syncing';
-- name: MarkOutboundOperationUncertain :exec
UPDATE outbound_operations SET
delivery_status = 'uncertain',
claimed_at = NULL,
last_error = ?,
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status = 'delivering';
@@ -81,6 +178,23 @@ UPDATE outbound_operations SET
delivery_status = 'failed',
claimed_at = NULL,
last_error = ?,
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ?
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND delivery_status IN ('delivering', 'uncertain');
@@ -89,6 +203,23 @@ UPDATE outbound_operations SET
delivery_status = 'uncertain',
claimed_at = NULL,
last_error = 'connector restarted while operation was delivering',
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector_restart_uncertain'
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector restarted while operation was delivering'
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE delivery_status = 'delivering';
@@ -97,6 +228,24 @@ UPDATE outbound_operations SET
delivery_status = 'failed',
claimed_at = NULL,
last_error = 'operation result remained uncertain beyond the observation window',
result_sync_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending'
ELSE result_sync_status
END,
result_sync_next_at = NULL,
result_status = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed'
ELSE result_status
END,
result_error_code = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain_timeout'
ELSE result_error_code
END,
result_error_message = CASE
WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'operation result remained uncertain beyond the observation window'
ELSE result_error_message
END,
updated_at = CURRENT_TIMESTAMP
WHERE delivery_status = 'uncertain'
AND result_sync_status <> 'syncing'
AND julianday(updated_at) <= julianday(?);
@@ -167,6 +167,14 @@ func (m *Manager) SyncClassifications(ctx context.Context, accountID int64, even
if !ok {
return errors.New("classification reporter is unavailable")
}
account, err := m.store.Reader().GetAccountByID(ctx, accountID)
if err != nil {
return fmt.Errorf("load connector account: %w", err)
}
if account.GochatInboxID <= 0 {
return errors.New("GoChat inbox mapping is invalid")
}
inboxID := account.GochatInboxID
var catalog swt.ClassificationCatalog
if err := m.WithSession(ctx, accountID, func(session swt.Session) error {
var err error
@@ -179,11 +187,11 @@ func (m *Manager) SyncClassifications(ctx context.Context, accountID int64, even
if errors.As(err, &protocolErr) && protocolErr.Code != "" {
code = protocolErr.Code
}
_ = statusReporter.UpdateClassificationSyncStatus(ctx, accountID, eventID, "failed", code, err.Error())
_ = statusReporter.UpdateClassificationSyncStatus(ctx, inboxID, eventID, "failed", code, err.Error())
}
return err
}
return reporter.UpdateClassificationCatalog(ctx, accountID, eventID, gochat.ClassificationCatalog{
return reporter.UpdateClassificationCatalog(ctx, inboxID, eventID, gochat.ClassificationCatalog{
ConversationKinds: convertConversationKinds(catalog.ConversationKinds),
CustomerColors: convertCustomerColors(catalog.CustomerColors),
})
@@ -9,6 +9,7 @@ import (
"time"
dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated"
"github.com/gochat/gochat/channels/shangwutong/internal/gochat"
"github.com/gochat/gochat/channels/shangwutong/internal/store"
"github.com/gochat/gochat/channels/shangwutong/internal/swt"
)
@@ -119,6 +120,38 @@ func TestSupervisorRejectsCandidatePasswordWithoutReplacingWorkingSession(t *tes
manager.Wait()
}
func TestManagerClassificationCallbacksUseGoChatInboxID(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
account := createRunnableAccount(t, ctx, database)
protocol := newFakeProtocol()
reporter := &classificationReporter{}
manager, err := NewManager(database, protocol, reporter, nil, 1)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
manager.Stop()
manager.Wait()
})
manager.initialDelay = func(time.Duration) time.Duration { return 0 }
manager.heartbeatInterval = time.Hour
if err := manager.Start(ctx); err != nil {
t.Fatal(err)
}
waitSignal(t, protocol.logins, "classification login")
if err := manager.SyncClassifications(ctx, account.ID, "classification:1"); err != nil {
t.Fatal(err)
}
if reporter.catalogInboxID != account.GochatInboxID {
t.Fatalf("catalog inbox ID = %d, want GoChat inbox ID %d", reporter.catalogInboxID, account.GochatInboxID)
}
}
func TestManagerMergesTypingIntoNextHeartbeat(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db"))
@@ -258,6 +291,10 @@ func (f *fakeProtocol) Heartbeat(_ context.Context, session swt.Session, cursor
return swt.HeartbeatResult{Status: swt.HeartbeatOK}, nil
}
func (f *fakeProtocol) FetchClassificationCatalog(context.Context, swt.Session) (swt.ClassificationCatalog, error) {
return swt.ClassificationCatalog{ConversationKinds: []swt.ConversationKind{{ID: "kind-1", Name: "Normal"}}}, nil
}
func (f *fakeProtocol) SetPresence(_ context.Context, _ swt.Session, presence swt.Presence) error {
f.mu.Lock()
if presence == swt.PresenceOffline {
@@ -274,6 +311,23 @@ func (f *fakeProtocol) offlineCalls() int {
return f.offline
}
type classificationReporter struct {
catalogInboxID int64
}
func (r *classificationReporter) UpdateInboxStatus(context.Context, int64, gochat.InboxStatus) error {
return nil
}
func (r *classificationReporter) UpdateClassificationCatalog(_ context.Context, inboxID int64, _ string, _ gochat.ClassificationCatalog) error {
r.catalogInboxID = inboxID
return nil
}
func (r *classificationReporter) UpdateClassificationSyncStatus(context.Context, int64, string, string, string, string) error {
return nil
}
func waitSignal[T any](t *testing.T, signal <-chan T, name string) T {
t.Helper()
select {
@@ -72,6 +72,9 @@ func serve(ctx context.Context) error {
if _, err := database.Writer().RecoverOutboundOperationsAsUncertain(ctx); err != nil {
return fmt.Errorf("recover outbound operation queue: %w", err)
}
if _, err := database.Writer().RecoverOutboundOperationResults(ctx); err != nil {
return fmt.Errorf("recover outbound operation result queue: %w", err)
}
if _, err := database.Writer().RecoverOutboundStatusSyncs(ctx); err != nil {
return fmt.Errorf("recover outbound status sync queue: %w", err)
}
@@ -87,7 +87,7 @@ func TestOperationalCommands(t *testing.T) {
t.Fatalf("%v produced no output", args)
}
}
if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 7 {
if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 8 {
t.Fatalf("backup version = %d, %v", version, err)
}
@@ -102,6 +102,11 @@ func (o *Outbound) Start(ctx context.Context) {
o.wg.Add(1)
go o.operationLoop(ctx)
}
resultWorkers := min(o.workers, 4)
for range resultWorkers {
o.wg.Add(1)
go o.operationResultLoop(ctx)
}
o.wg.Add(1)
go o.uncertainLoop(ctx)
}
@@ -154,6 +159,21 @@ func (o *Outbound) operationLoop(ctx context.Context) {
}
}
func (o *Outbound) operationResultLoop(ctx context.Context) {
defer o.wg.Done()
for ctx.Err() == nil {
worked, err := o.processOperationResult(ctx)
if err != nil && ctx.Err() == nil {
o.logger.WithFields(logrus.Fields{
"component": "outbound_operation_result_worker", "operation": "sync", "result": "failed",
}).WithError(err).Warn("outbound operation result sync failed")
}
if !worked && !wait(ctx, 200*time.Millisecond) {
return
}
}
}
func (o *Outbound) Wait() { o.wg.Wait() }
func (o *Outbound) SetMetrics(metrics *observability.Metrics) { o.metrics = metrics }
@@ -605,10 +625,8 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) {
if !classificationSupported {
return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")}
}
var payload struct {
ChatKindID string `json:"chat_kind_id"`
}
if err := json.Unmarshal([]byte(operation.Payload), &payload); err != nil || strings.TrimSpace(payload.ChatKindID) == "" {
payload, err := decodeClassificationOperation(operation.Payload)
if err != nil || strings.TrimSpace(payload.ChatKindID) == "" {
return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("chat_kind_id is required")}
}
return classificationSender.SetConversationKind(ctx, session, operation.SwtSid, payload.ChatKindID)
@@ -616,12 +634,8 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) {
if !classificationSupported {
return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")}
}
var payload struct {
CustomerColorID string `json:"customer_color_id"`
CustomerColorName string `json:"customer_color_name"`
CID string `json:"cid"`
}
if err := json.Unmarshal([]byte(operation.Payload), &payload); err != nil || strings.TrimSpace(payload.CustomerColorID) == "" || strings.TrimSpace(payload.CID) == "" {
payload, err := decodeClassificationOperation(operation.Payload)
if err != nil || strings.TrimSpace(payload.CustomerColorID) == "" || strings.TrimSpace(payload.CID) == "" {
return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("customer_color_id and cid are required")}
}
return classificationSender.ChangeCustomerColor(ctx, session, operation.SwtSid, payload.CustomerColorID, payload.CustomerColorName, payload.CID)
@@ -658,17 +672,15 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) {
}
})
if err == nil {
if reportErr := o.reportClassificationStatus(ctx, operation, "succeeded", nil); reportErr != nil {
return true, o.retryOperation(operation, "classification result callback failed: "+reportErr.Error())
}
return true, o.store.Writer().CompleteOutboundOperation(ctx, operation.ID)
}
var protocolErr *swt.Error
if errors.As(err, &protocolErr) {
if protocolErr.Uncertain {
detail := protocolErr.Error()
_ = o.reportClassificationStatus(ctx, operation, "uncertain", protocolErr)
return true, o.store.Writer().MarkOutboundOperationUncertain(ctx, dbgen.MarkOutboundOperationUncertainParams{LastError: &detail, ID: operation.ID})
detail, code := protocolErr.Error(), protocolErr.Code
return true, o.store.Writer().MarkOutboundOperationUncertain(ctx, dbgen.MarkOutboundOperationUncertainParams{
LastError: &detail, ResultErrorCode: &code, ResultErrorMessage: &detail, ID: operation.ID,
})
}
if protocolErr.Code == "tickint_reset" || protocolErr.Code == "cache_null" {
_ = o.sessions.InvalidateSession(ctx, operation.AccountID)
@@ -677,38 +689,79 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) {
if protocolErr.Retryable && operation.Attempts < 10 {
return true, o.retryOperation(operation, protocolErr.Error())
}
return true, o.failOperation(operation, protocolErr.Error())
return true, o.failOperation(operation, protocolErr.Code, protocolErr.Error())
}
if operation.Attempts < 10 {
return true, o.retryOperation(operation, err.Error())
}
return true, o.failOperation(operation, err.Error())
return true, o.failOperation(operation, "delivery_exhausted", err.Error())
}
func (o *Outbound) reportClassificationStatus(ctx context.Context, operation *dbgen.OutboundOperation, status string, cause error) error {
type classificationOperationData struct {
ConversationID uint `json:"conversation_id"`
ChatKindID string `json:"chat_kind_id"`
CustomerColorID string `json:"customer_color_id"`
CustomerColorName string `json:"customer_color_name"`
CID string `json:"cid"`
}
type classificationOperationEnvelope struct {
Data classificationOperationData `json:"data"`
}
func decodeClassificationOperation(payload string) (classificationOperationData, error) {
var envelope classificationOperationEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return classificationOperationData{}, err
}
return envelope.Data, nil
}
func (o *Outbound) processOperationResult(ctx context.Context) (bool, error) {
operation, err := o.store.Writer().ClaimOutboundOperationResult(ctx)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
if err := o.reportClassificationStatus(ctx, operation); err != nil {
detail := "classification result callback failed: " + err.Error()
if operation.ResultSyncAttempts < 10 {
return true, o.retryOperationResult(operation, detail)
}
return true, o.failOperationResult(operation, detail)
}
return true, o.store.Writer().CompleteOutboundOperationResult(ctx, operation.ID)
}
func (o *Outbound) reportClassificationStatus(ctx context.Context, operation *dbgen.OutboundOperation) error {
reporter, ok := o.results.(ClassificationResultClient)
if !ok || (operation.Operation != "set_chat_kind" && operation.Operation != "set_customer_color") {
return nil
if !ok {
return errors.New("classification result reporter is unavailable")
}
var envelope struct {
Data struct {
ConversationID uint `json:"conversation_id"`
ChatKindID string `json:"chat_kind_id"`
CustomerColorID string `json:"customer_color_id"`
} `json:"data"`
status := value(operation.ResultStatus)
if status != "succeeded" && status != "failed" && status != "uncertain" {
return errors.New("classification result status is invalid")
}
if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || envelope.Data.ConversationID == 0 {
data, err := decodeClassificationOperation(operation.Payload)
if err != nil || data.ConversationID == 0 {
return errors.New("classification result payload is invalid")
}
errorCode, errorMessage := "", ""
if cause != nil {
errorMessage = cause.Error()
var protocolErr *swt.Error
if errors.As(cause, &protocolErr) {
errorCode = protocolErr.Code
}
if operation.Operation == "set_chat_kind" && strings.TrimSpace(data.ChatKindID) == "" {
return errors.New("chat_kind_id is required")
}
return reporter.UpdateClassificationStatus(ctx, operation.AccountID, envelope.Data.ConversationID, operation.EventID, operation.Operation, status, envelope.Data.ChatKindID, envelope.Data.CustomerColorID, errorCode, errorMessage)
if operation.Operation == "set_customer_color" && (strings.TrimSpace(data.CustomerColorID) == "" || strings.TrimSpace(data.CID) == "") {
return errors.New("customer_color_id and cid are required")
}
account, err := o.store.Reader().GetAccountByID(ctx, operation.AccountID)
if err != nil {
return fmt.Errorf("load GoChat inbox mapping: %w", err)
}
if account.GochatInboxID <= 0 {
return errors.New("GoChat inbox mapping is invalid")
}
return reporter.UpdateClassificationStatus(ctx, account.GochatInboxID, data.ConversationID, operation.EventID, operation.Operation, status, data.ChatKindID, data.CustomerColorID, value(operation.ResultErrorCode), value(operation.ResultErrorMessage))
}
func (o *Outbound) retry(message *dbgen.OutboundMessage, detail string) error {
@@ -769,8 +822,21 @@ func (o *Outbound) retryOperation(operation *dbgen.OutboundOperation, detail str
})
}
func (o *Outbound) failOperation(operation *dbgen.OutboundOperation, detail string) error {
return o.store.Writer().FailOutboundOperation(context.Background(), dbgen.FailOutboundOperationParams{LastError: &detail, ID: operation.ID})
func (o *Outbound) failOperation(operation *dbgen.OutboundOperation, code, detail string) error {
return o.store.Writer().FailOutboundOperation(context.Background(), dbgen.FailOutboundOperationParams{
LastError: &detail, ResultErrorCode: &code, ResultErrorMessage: &detail, ID: operation.ID,
})
}
func (o *Outbound) retryOperationResult(operation *dbgen.OutboundOperation, detail string) error {
next := time.Now().Add(backoff(operation.ResultSyncAttempts, 5*time.Minute))
return o.store.Writer().RetryOutboundOperationResult(context.Background(), dbgen.RetryOutboundOperationResultParams{
ResultSyncNextAt: &next, LastError: &detail, ID: operation.ID,
})
}
func (o *Outbound) failOperationResult(operation *dbgen.OutboundOperation, detail string) error {
return o.store.Writer().FailOutboundOperationResult(context.Background(), dbgen.FailOutboundOperationResultParams{LastError: &detail, ID: operation.ID})
}
func backoff(attempts int64, maximum time.Duration) time.Duration {
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -622,6 +623,137 @@ func TestOutboundConversationEndOperationUsesSameDurableWorker(t *testing.T) {
}
}
func TestClassificationOperationsUseNestedPayloadAndReportWithInboxID(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
tests := []struct {
name string
operation string
payload string
}{
{
name: "chat kind",
operation: "set_chat_kind",
payload: `{"schema_version":1,"event":"conversation_classification_changed","event_id":"classification:kind","occurred_at":"2026-01-01T00:00:00Z","account_id":1,"inbox_id":10,"data":{"conversation_id":88,"sid":"visitor","chat_kind_id":"kind-1"}}`,
},
{
name: "customer color",
operation: "set_customer_color",
payload: `{"schema_version":1,"event":"conversation_classification_changed","event_id":"classification:color","occurred_at":"2026-01-01T00:00:01Z","account_id":1,"inbox_id":10,"data":{"conversation_id":89,"sid":"visitor","cid":"cid-1","customer_color_id":"color-1","customer_color_name":"VIP"}}`,
},
}
for index, test := range tests {
t.Run(test.name, func(t *testing.T) {
operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: fmt.Sprintf("classification:%d", index),
Operation: test.operation, Payload: test.payload, OccurredAt: time.Now().Add(time.Duration(index) * time.Second),
})
if err != nil {
t.Fatal(err)
}
sender := &operationSender{}
results := &classificationResultRecorder{}
worker, err := NewOutbound(database, sessionStub{}, sender, results, nil, 1)
if err != nil {
t.Fatal(err)
}
if worked, err := worker.processOperation(ctx); err != nil || !worked {
t.Fatalf("operation = %v, %v", worked, err)
}
loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID)
if err != nil || loaded.DeliveryStatus != "delivered" || loaded.ResultSyncStatus != "pending" || value(loaded.ResultStatus) != "succeeded" {
t.Fatalf("loaded before callback = %#v, %v", loaded, err)
}
if worked, err := worker.processOperationResult(ctx); err != nil || !worked {
t.Fatalf("result = %v, %v", worked, err)
}
if len(results.calls) != 1 || results.calls[0].inboxID != account.GochatInboxID || results.calls[0].conversationID != 88+uint(index) || results.calls[0].operation != test.operation || results.calls[0].status != "succeeded" {
t.Fatalf("classification result = %#v", results.calls)
}
if test.operation == "set_chat_kind" && (sender.kindCalls != 1 || sender.kindID != "kind-1") {
t.Fatalf("kind sender = %#v", sender)
}
if test.operation == "set_customer_color" && (sender.colorCalls != 1 || sender.colorID != "color-1" || sender.colorCID != "cid-1") {
t.Fatalf("color sender = %#v", sender)
}
loaded, err = database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID)
if err != nil || loaded.ResultSyncStatus != "synced" {
t.Fatalf("loaded after callback = %#v, %v", loaded, err)
}
})
}
}
func TestClassificationResultRetryDoesNotRepeatSWTOperation(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "classification:retry",
Operation: "set_chat_kind", Payload: `{"data":{"conversation_id":88,"sid":"visitor","chat_kind_id":"kind-1"}}`, OccurredAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
sender := &operationSender{}
results := &classificationResultRecorder{err: errors.New("GoChat unavailable")}
worker, _ := NewOutbound(database, sessionStub{}, sender, results, nil, 1)
if worked, err := worker.processOperation(ctx); err != nil || !worked {
t.Fatalf("operation = %v, %v", worked, err)
}
claimed, err := database.Writer().ClaimOutboundOperationResult(ctx)
if err != nil {
t.Fatal(err)
}
if err := worker.reportClassificationStatus(ctx, claimed); err == nil {
t.Fatal("expected result callback failure")
}
past := time.Now().Add(-time.Second)
if err := database.Writer().RetryOutboundOperationResult(ctx, dbgen.RetryOutboundOperationResultParams{
ResultSyncNextAt: &past, LastError: stringPointer("GoChat unavailable"), ID: claimed.ID,
}); err != nil {
t.Fatal(err)
}
results.err = nil
if worked, err := worker.processOperationResult(ctx); err != nil || !worked {
t.Fatalf("retried result = %v, %v", worked, err)
}
if sender.kindCalls != 1 || len(results.calls) != 1 {
t.Fatalf("external calls repeated: sender=%#v results=%#v", sender, results.calls)
}
loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID)
if err != nil || loaded.DeliveryStatus != "delivered" || loaded.ResultSyncStatus != "synced" {
t.Fatalf("loaded = %#v, %v", loaded, err)
}
}
func TestClassificationFailureIsReportedAfterExternalFailure(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "classification:failed",
Operation: "set_customer_color", Payload: `{"data":{"conversation_id":88,"sid":"visitor","cid":"cid-1","customer_color_id":"color-1"}}`, OccurredAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
sender := &operationSender{err: &swt.Error{Operation: "set_customer_color", Code: "rejected", Retryable: false, Err: errors.New("rejected")}}
results := &classificationResultRecorder{}
worker, _ := NewOutbound(database, sessionStub{}, sender, results, nil, 1)
if worked, err := worker.processOperation(ctx); err != nil || !worked {
t.Fatalf("operation = %v, %v", worked, err)
}
loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID)
if err != nil || loaded.DeliveryStatus != "failed" || loaded.ResultSyncStatus != "pending" || value(loaded.ResultStatus) != "failed" || value(loaded.ResultErrorCode) != "rejected" {
t.Fatalf("failed operation = %#v, %v", loaded, err)
}
if worked, err := worker.processOperationResult(ctx); err != nil || !worked {
t.Fatalf("failure result = %v, %v", worked, err)
}
if len(results.calls) != 1 || results.calls[0].inboxID != account.GochatInboxID || results.calls[0].status != "failed" || results.calls[0].errorCode != "rejected" {
t.Fatalf("failure callback = %#v", results.calls)
}
}
func TestOutboundAcceptTransferPersistsSuccessAndRetry(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
@@ -944,6 +1076,11 @@ type operationSender struct {
transferSID string
otherLoginName string
sentSID string
kindCalls int
kindID string
colorCalls int
colorID string
colorCID string
err error
}
@@ -990,6 +1127,16 @@ func (s *operationSender) TransferConversation(_ context.Context, _ swt.Session,
return s.err
}
func (s *operationSender) SetConversationKind(_ context.Context, _ swt.Session, sid, kindID string) error {
s.kindCalls, s.sentSID, s.kindID = s.kindCalls+1, sid, kindID
return s.err
}
func (s *operationSender) ChangeCustomerColor(_ context.Context, _ swt.Session, sid, colorID, _, cid string) error {
s.colorCalls, s.sentSID, s.colorID, s.colorCID = s.colorCalls+1, sid, colorID, cid
return s.err
}
type partialSender struct {
textCalls int
imageCalls int
@@ -1063,4 +1210,30 @@ func (r *resultRecorder) UpdateMessageStatus(_ context.Context, inboxID, message
return nil
}
type classificationResultCall struct {
inboxID int64
conversationID uint
eventID string
operation string
status string
errorCode string
}
type classificationResultRecorder struct {
resultRecorder
calls []classificationResultCall
err error
}
func (r *classificationResultRecorder) UpdateClassificationStatus(_ context.Context, inboxID int64, conversationID uint, eventID, operation, status, _, _, errorCode, _ string) error {
if r.err != nil {
return r.err
}
r.calls = append(r.calls, classificationResultCall{
inboxID: inboxID, conversationID: conversationID, eventID: eventID,
operation: operation, status: status, errorCode: errorCode,
})
return nil
}
func stringPointer(value string) *string { return &value }
@@ -134,7 +134,7 @@ type OutboundOperationInput struct {
func (s *Store) EnqueueOutboundOperation(ctx context.Context, input OutboundOperationInput) (*dbgen.OutboundOperation, bool, error) {
if input.AccountID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" ||
(input.Operation != "end_conversation" && input.Operation != "change_contact_name" && input.Operation != "accept_transfer") || input.OccurredAt.IsZero() {
(input.Operation != "end_conversation" && input.Operation != "change_contact_name" && input.Operation != "accept_transfer" && input.Operation != "transfer_conversation" && input.Operation != "set_chat_kind" && input.Operation != "set_customer_color") || input.OccurredAt.IsZero() {
return nil, false, errors.New("valid outbound operation fields are required")
}
existing, err := s.writerQueries.GetOutboundOperationByEventID(ctx, input.EventID)
@@ -620,7 +620,7 @@ func TestOnlineBackupCanBeOpenedReadOnly(t *testing.T) {
t.Fatalf("backup mode = %v", info.Mode().Perm())
}
version, err := InspectDatabase(ctx, backup)
if err != nil || version != 7 {
if err != nil || version != 8 {
t.Fatalf("backup version = %d, %v", version, err)
}
}
+1
View File
@@ -5,6 +5,7 @@ sql:
- "db/migrations/001_init.up.sql"
- "db/migrations/006_add_xst_outbound_stages.up.sql"
- "db/migrations/007_add_xst_reception_sync.up.sql"
- "db/migrations/008_add_classification_operation_results.up.sql"
queries: "db/queries"
gen:
go: