Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions pkg/connector/send_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broad match on TalkException code 5: IsTalkExceptionNotFound matches any code:5 reason:"not found" TalkException — the helper's own docstring in pkg/line/errors.go:148-150 warns that callers must interpret method context. Here the only additional guard is msg.RelatedMessageID != "", so if LINE's sendMessage ever surfaces a non-reply "not found" (e.g. recipient/chat missing) on a message that also carried a reply relation, we'd strip the reply and re-send once. Today that just costs one extra doomed request plus a misleading could not find reply target log; if the second attempt were ever to succeed transiently, the reply would be silently downgraded to a non-reply message on LINE. Consider tightening either the helper (parse data.reason / method) or the guard (e.g. only retry when the error string references the reply-target field) once you have a concrete signature for the reply-not-found variant.

}

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)
Expand Down
45 changes: 45 additions & 0 deletions pkg/connector/send_message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
21 changes: 20 additions & 1 deletion pkg/line/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions pkg/line/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading