diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index 3b98017..05e75cf 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -783,6 +783,20 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } } + if shouldRetrySendWithoutReplyRelation(lineMsg, err) { + relatedMessageID := lineMsg.RelatedMessageID + clearReplyRelation(lineMsg) + lc.UserLogin.Bridge.Log.Warn(). + Err(err). + Str("chat_mid", portalMid). + Str("related_message_id", relatedMessageID). + Msg("SendMessage failed because LINE could not find reply target, retrying without reply relation") + + retryReqSeq := int(time.Now().UnixMilli() % 1_000_000_000) + lc.trackReqSeq(retryReqSeq) + sentMsg, err = sendLineMessage(retryReqSeq, lineMsg) + } + if err != nil { return nil, err } @@ -862,6 +876,21 @@ func contentTypeForMsgType(msgType event.MessageType) int { } } +func shouldRetrySendWithoutReplyRelation(msg *line.Message, err error) bool { + return msg != nil && msg.RelatedMessageID != "" && line.IsTalkExceptionNotFound(err) +} + +func clearReplyRelation(msg *line.Message) { + msg.RelatedMessageID = "" + msg.MessageRelationType = 0 + msg.RelatedMessageServiceCode = 0 + if msg.ContentMetadata != nil { + delete(msg.ContentMetadata, "message_relation_server_message_id") + delete(msg.ContentMetadata, "message_relation_type") + delete(msg.ContentMetadata, "message_relation_service_code") + } +} + func (lc *LineClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { reqSeq := int(time.Now().UnixMilli() % 1_000_000_000) lc.trackReqSeq(reqSeq) diff --git a/pkg/connector/send_message_test.go b/pkg/connector/send_message_test.go index 7b4a502..3bb308e 100644 --- a/pkg/connector/send_message_test.go +++ b/pkg/connector/send_message_test.go @@ -67,3 +67,48 @@ func TestLineGroupE2EEFetchFailureErrorWrapsMissingPrivateKeyStatus(t *testing.T t.Fatalf("status = %#v, want reconnect failure notice", status) } } + +func TestShouldRetrySendWithoutReplyRelation(t *testing.T) { + err := errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":5,"reason":"not found","parameterMap":null}}`) + msg := &line.Message{RelatedMessageID: "1234567890"} + + if !shouldRetrySendWithoutReplyRelation(msg, err) { + t.Fatal("expected related-message not found error to trigger retry") + } + + msg.RelatedMessageID = "" + if shouldRetrySendWithoutReplyRelation(msg, err) { + t.Fatal("message without reply relation should not retry") + } + + msg.RelatedMessageID = "1234567890" + if shouldRetrySendWithoutReplyRelation(msg, errors.New("other error")) { + t.Fatal("non-LINE not found error should not retry") + } +} + +func TestClearReplyRelation(t *testing.T) { + msg := &line.Message{ + RelatedMessageID: "1234567890", + MessageRelationType: 3, + RelatedMessageServiceCode: 1, + ContentMetadata: map[string]string{ + "message_relation_server_message_id": "1234567890", + "message_relation_type": "3", + "message_relation_service_code": "1", + "keep": "value", + }, + } + + clearReplyRelation(msg) + + if msg.RelatedMessageID != "" || msg.MessageRelationType != 0 || msg.RelatedMessageServiceCode != 0 { + t.Fatalf("reply relation was not cleared: %#v", msg) + } + if _, ok := msg.ContentMetadata["message_relation_server_message_id"]; ok { + t.Fatal("content metadata relation ID was not cleared") + } + if got := msg.ContentMetadata["keep"]; got != "value" { + t.Fatalf("unrelated content metadata = %q, want value", got) + } +} diff --git a/pkg/line/errors.go b/pkg/line/errors.go index 74fb9f3..c99ccf9 100644 --- a/pkg/line/errors.go +++ b/pkg/line/errors.go @@ -145,6 +145,23 @@ func IsGroupKeyNotRegisteredError(err error) bool { strings.Contains(msg, "group key is not registered") } +// IsTalkExceptionNotFound returns true when LINE wraps a TalkException code 5 +// "not found" response. Callers must interpret the method context themselves: +// the same code can mean different missing resources for different Talk APIs. +func IsTalkExceptionNotFound(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return hasResponseErrorCode(msg) && + strings.Contains(msg, "talkexception") && + (strings.Contains(msg, "\"code\":5,") || + strings.Contains(msg, "\"code\":5}") || + strings.Contains(msg, "\"code\": 5,")) && + (strings.Contains(msg, "\"reason\":\"not found\"") || + strings.Contains(msg, "\"reason\": \"not found\"")) +} + // IsNotAMemberError returns true when the API reports the user is not a member of a chat. func IsNotAMemberError(err error) bool { if err == nil { @@ -167,7 +184,9 @@ func IsInvalidPaidReactionType(err error) bool { } func hasResponseErrorCode(msg string) bool { - return strings.Contains(msg, "\"code\":10051") || strings.Contains(msg, "code 10051") + return strings.Contains(msg, "\"code\":10051") || + strings.Contains(msg, "\"code\": 10051") || + strings.Contains(msg, "code 10051") } func isNoUsableE2EEGroupKeyTalkException(message string, data talkExceptionData) bool { diff --git a/pkg/line/errors_test.go b/pkg/line/errors_test.go index 652f128..372e850 100644 --- a/pkg/line/errors_test.go +++ b/pkg/line/errors_test.go @@ -94,3 +94,19 @@ func TestIsUnauthorizedStatus(t *testing.T) { } } } + +func TestIsTalkExceptionNotFound(t *testing.T) { + err := errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":5,"reason":"not found","parameterMap":null}}`) + if !IsTalkExceptionNotFound(err) { + t.Fatal("expected TalkException code 5 not found to be detected") + } + if IsTalkExceptionNotFound(errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":10,"reason":"not a member","parameterMap":null}}`)) { + t.Fatal("not-a-member should not be classified as not-found") + } + if IsTalkExceptionNotFound(errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","message":"TalkException","code":5,"reason":"different","parameterMap":null}}`)) { + t.Fatal("code 5 with a different reason should not be classified as not-found") + } + if IsTalkExceptionNotFound(nil) { + t.Fatal("nil should not be classified as not-found") + } +}