From ffaeed7d63bc3a14c3fd97547cab7e35a7b51747 Mon Sep 17 00:00:00 2001 From: Evan Lin Date: Tue, 1 Sep 2026 15:24:16 +0800 Subject: [PATCH] fix: move unreachable event cases out of the message content switch MemberJoinedEvent, MemberLeftEvent, FollowEvent and BeaconEvent were cases on the inner `switch e.Message.(type)`. They are event types, not message contents, so they could never match at runtime. This compiled only because MessageContentInterface is `GetType() string`, which the event types happen to satisfy, so the compiler could not flag them as impossible type switch cases. Moving them to the outer switch over cb.Events exposes a second bug: every case asserted `e.Source.(webhook.UserSource)`, but Source is a SourceInterface whose concrete type depends on the chat. Member joined/left only occur in group and multi-person chats, so the source is a GroupSource or RoomSource and the assertion panics. - Move the four cases to the outer event switch - Add sourceID to describe user, group and room sources without panicking - Report the joined/left members from e.Joined / e.Left rather than the source, and the hwid from e.Beacon - Guard the optional pointer sub-objects with orZero so a payload that omits them logs instead of crashing - Fix the copy-pasted "Member joined" log on the member left case - Add tests covering the decoded payload shape and the helpers --- main.go | 57 +++++++++++++++++++++----- main_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 main_test.go diff --git a/main.go b/main.go index ffd0ddc0..999411eb 100644 --- a/main.go +++ b/main.go @@ -85,19 +85,20 @@ func main() { } else { log.Println("Sent sticker reply.") } - case webhook.MemberJoinedEvent: - log.Printf("Member joined: %s\n", e.Source.(webhook.UserSource).UserId) - case webhook.MemberLeftEvent: - log.Printf("Member joined: %s\n", e.Source.(webhook.UserSource).UserId) - case webhook.FollowEvent: - log.Printf("Follow event: %s\n", e.Source.(webhook.UserSource).UserId) - case webhook.BeaconEvent: - log.Printf("Beacon event: %s\n", e.Source.(webhook.UserSource).UserId) default: log.Printf("Unsupported message content: %T\n", e.Message) } + case webhook.FollowEvent: + log.Printf("Followed by %s\n", sourceID(e.Source)) + case webhook.MemberJoinedEvent: + log.Printf("Members joined %s: %s\n", sourceID(e.Source), userIDs(orZero(e.Joined).Members)) + case webhook.MemberLeftEvent: + log.Printf("Members left %s: %s\n", sourceID(e.Source), userIDs(orZero(e.Left).Members)) + case webhook.BeaconEvent: + beacon := orZero(e.Beacon) + log.Printf("Beacon %q from %s: hwid=%s\n", beacon.Type, sourceID(e.Source), beacon.Hwid) default: - log.Printf("Unsupported message: %T\n", event) + log.Printf("Unsupported event: %T\n", event) } } }) @@ -113,3 +114,41 @@ func main() { log.Fatal(err) } } + +// sourceID describes where an event came from. The concrete type behind +// webhook.SourceInterface depends on the chat: 1:1 chats carry a UserSource, +// while group and multi-person chats carry a GroupSource or RoomSource, so this +// must never assume UserSource. +func sourceID(src webhook.SourceInterface) string { + switch s := src.(type) { + case webhook.UserSource: + return "user " + s.UserId + case webhook.GroupSource: + return "group " + s.GroupId + case webhook.RoomSource: + return "room " + s.RoomId + default: + return fmt.Sprintf("unknown source (%T)", src) + } +} + +// orZero dereferences p, returning the zero value when it is nil. The webhook +// models optional sub-objects as pointers, so payloads that omit them must not +// crash the handler. +func orZero[T any](p *T) T { + if p == nil { + var zero T + return zero + } + return *p +} + +// userIDs collects the user IDs out of the member list carried by member +// joined/left events. +func userIDs(members []webhook.UserSource) []string { + ids := make([]string, len(members)) + for i, m := range members { + ids[i] = m.UserId + } + return ids +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..4b4d51ad --- /dev/null +++ b/main_test.go @@ -0,0 +1,110 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "slices" + "testing" + + "github.com/line/line-bot-sdk-go/v8/linebot/webhook" +) + +// memberJoinedPayload is a member joined event as the LINE platform sends it. +// Note the source: member joined/left only happen in group and multi-person +// chats, so the source is never a UserSource. +const memberJoinedPayload = `{ + "type": "memberJoined", + "timestamp": 1462629479859, + "mode": "active", + "webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR", + "deliveryContext": { "isRedelivery": false }, + "source": { "type": "group", "groupId": "Ca56f94637c...", "userId": "U4af4980629..." }, + "replyToken": "0f3779fba3b349968c5d07db31eab56f", + "joined": { + "members": [ + { "type": "user", "userId": "U4af4980629..." }, + { "type": "user", "userId": "U91eeaf62d9..." } + ] + } +}` + +// TestMemberJoinedIsEventNotMessageContent pins down the two facts the previous +// handler got wrong: a memberJoined payload decodes to an *event*, so it can +// only be matched on the outer switch over cb.Events, and its source is a +// GroupSource, so asserting webhook.UserSource on it panics. +func TestMemberJoinedIsEventNotMessageContent(t *testing.T) { + event, err := webhook.UnmarshalEvent([]byte(memberJoinedPayload)) + if err != nil { + t.Fatalf("UnmarshalEvent: %v", err) + } + + e, ok := event.(webhook.MemberJoinedEvent) + if !ok { + t.Fatalf("got %T, want webhook.MemberJoinedEvent", event) + } + if _, isMessage := event.(webhook.MessageEvent); isMessage { + t.Error("memberJoined decoded as a MessageEvent; it can never appear in the e.Message switch") + } + if _, isUser := e.Source.(webhook.UserSource); isUser { + t.Error("source decoded as UserSource; the group case would not be exercised") + } + + if got, want := sourceID(e.Source), "group Ca56f94637c..."; got != want { + t.Errorf("sourceID = %q, want %q", got, want) + } + want := []string{"U4af4980629...", "U91eeaf62d9..."} + if got := userIDs(orZero(e.Joined).Members); !slices.Equal(got, want) { + t.Errorf("userIDs = %v, want %v", got, want) + } +} + +func TestSourceID(t *testing.T) { + tests := []struct { + name string + src webhook.SourceInterface + want string + }{ + {"user", webhook.UserSource{UserId: "U1"}, "user U1"}, + {"group", webhook.GroupSource{GroupId: "C1", UserId: "U1"}, "group C1"}, + {"room", webhook.RoomSource{RoomId: "R1", UserId: "U1"}, "room R1"}, + {"nil", nil, "unknown source ()"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sourceID(tt.src); got != tt.want { + t.Errorf("sourceID = %q, want %q", got, tt.want) + } + }) + } +} + +func TestUserIDs(t *testing.T) { + got := userIDs([]webhook.UserSource{{UserId: "U1"}, {UserId: "U2"}}) + if want := []string{"U1", "U2"}; !slices.Equal(got, want) { + t.Errorf("userIDs = %v, want %v", got, want) + } + if got := userIDs(nil); len(got) != 0 { + t.Errorf("userIDs(nil) = %v, want empty", got) + } +} + +// TestOrZeroNil covers the payloads that omit an optional sub-object: the +// handler must log them, not panic. +func TestOrZeroNil(t *testing.T) { + if got := userIDs(orZero((*webhook.JoinedMembers)(nil)).Members); len(got) != 0 { + t.Errorf("userIDs = %v, want empty", got) + } + if got := orZero((*webhook.BeaconContent)(nil)).Hwid; got != "" { + t.Errorf("Hwid = %q, want empty", got) + } +}