fix(channels): harden error handling and type safety across Telegram, Zalo, Feishu (#247)

Telegram:
- Fix context cancellation in reaction timer callbacks — use context.Background()
  so emoji reactions still work after request context is cancelled
- Add comma-ok safety on sync.Map type assertions in OnReactionEvent/ClearReaction

Zalo:
- Add comma-ok safety on sync.Map type assertions for typing controllers
- Validate non-empty senderID before processing text/image messages

Feishu/Lark:
- Handle json.Unmarshal errors in all 7 LarkClient messaging methods instead of
  silently returning empty values
- Add 30s HTTP timeout for WebSocket endpoint request (was using DefaultClient)
- Eliminate double decryption in webhook handler — cache first result
- Replace goroutine+sleep with time.AfterFunc for dedup cleanup
- Add comma-ok safety on pairing debounce type assertion
- Use json.Marshal instead of fmt.Sprintf for image/file key JSON construction

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
Luan VuandLuvu182 authored and GitHub committed 2026-03-18 07:39:03 +07:00
1 parent 06e995ce03
commit 8cbd30f1ab
10 files changed
+64 -33

No files matched your search

+1 -1
View File
@@ -151,7 +151,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
// Debounce
if lastSent, ok := c.pairingDebounce.Load(senderID); ok {
if time.Since(lastSent.(time.Time)) < pairingDebounceTime {
if t, ok := lastSent.(time.Time); ok && time.Since(t) < pairingDebounceTime {
return
}
}
+2 -3
View File
@@ -440,10 +440,9 @@ func shouldUseCard(text string) bool {
func (c *Channel) isDuplicate(messageID string) bool {
_, loaded := c.dedup.LoadOrStore(messageID, struct{}{})
if !loaded {
go func() {
time.Sleep(5 * time.Minute)
time.AfterFunc(5*time.Minute, func() {
c.dedup.Delete(messageID)
}()
})
}
return loaded
}
@@ -30,7 +30,9 @@ func (c *LarkClient) SendMessage(ctx context.Context, receiveIDType, receiveID,
return nil, fmt.Errorf("send message: code=%d msg=%s", resp.Code, resp.Msg)
}
var data SendMessageResp
json.Unmarshal(resp.Data, &data)
if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &data, nil
}
@@ -55,7 +57,9 @@ func (c *LarkClient) UploadImage(ctx context.Context, data io.Reader) (string, e
var result struct {
ImageKey string `json:"image_key"`
}
json.Unmarshal(resp.Data, &result)
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.ImageKey, nil
}
@@ -79,7 +83,9 @@ func (c *LarkClient) UploadFile(ctx context.Context, data io.Reader, fileName, f
var result struct {
FileKey string `json:"file_key"`
}
json.Unmarshal(resp.Data, &result)
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.FileKey, nil
}
@@ -106,7 +112,9 @@ func (c *LarkClient) CreateCard(ctx context.Context, cardType, data string) (str
var result struct {
CardID string `json:"card_id"`
}
json.Unmarshal(resp.Data, &result)
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.CardID, nil
}
@@ -165,7 +173,9 @@ func (c *LarkClient) AddMessageReaction(ctx context.Context, messageID, emojiTyp
var result struct {
ReactionID string `json:"reaction_id"`
}
json.Unmarshal(resp.Data, &result)
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.ReactionID, nil
}
@@ -200,7 +210,9 @@ func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) {
OpenID string `json:"open_id"`
} `json:"bot"`
}
json.Unmarshal(resp.Data, &result)
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.Bot.OpenID, nil
}
@@ -220,6 +232,8 @@ func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (st
Name string `json:"name"`
} `json:"user"`
}
json.Unmarshal(resp.Data, &result)
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.User.Name, nil
}
+2 -8
View File
@@ -105,6 +105,7 @@ func NewWebhookHandler(verificationToken, encryptKey string, onMessage func(even
}
// Handle encrypted events
eventBody := body
if envelope.Encrypt != "" && encryptKey != "" {
decrypted, err := decryptEvent(envelope.Encrypt, encryptKey)
if err != nil {
@@ -112,6 +113,7 @@ func NewWebhookHandler(verificationToken, encryptKey string, onMessage func(even
http.Error(w, "decrypt failed", http.StatusBadRequest)
return
}
eventBody = decrypted
// Re-parse decrypted content
if err := json.Unmarshal(decrypted, &envelope); err != nil {
http.Error(w, "invalid decrypted json", http.StatusBadRequest)
@@ -128,14 +130,6 @@ func NewWebhookHandler(verificationToken, encryptKey string, onMessage func(even
// Parse as message event
var event MessageEvent
// Re-unmarshal the original (or decrypted) body as a full event
eventBody := body
if envelope.Encrypt != "" && encryptKey != "" {
decrypted, _ := decryptEvent(envelope.Encrypt, encryptKey)
if decrypted != nil {
eventBody = decrypted
}
}
if err := json.Unmarshal(eventBody, &event); err != nil {
slog.Debug("feishu webhook parse event failed", "error", err)
+2 -1
View File
@@ -189,7 +189,8 @@ func (c *WSClient) getWSEndpoint(ctx context.Context) (string, error) {
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("ws endpoint request: %w", err)
}
+11 -4
View File
@@ -2,6 +2,7 @@ package feishu
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
@@ -35,8 +36,11 @@ func (c *Channel) uploadFile(ctx context.Context, data io.Reader, fileName, file
// sendImage sends an image message using an image_key.
func (c *Channel) sendImage(ctx context.Context, chatID, receiveIDType, imageKey string) error {
content := fmt.Sprintf(`{"image_key":"%s"}`, imageKey)
_, err := c.client.SendMessage(ctx, receiveIDType, chatID, "image", content)
contentBytes, err := json.Marshal(map[string]string{"image_key": imageKey})
if err != nil {
return fmt.Errorf("marshal image content: %w", err)
}
_, err = c.client.SendMessage(ctx, receiveIDType, chatID, "image", string(contentBytes))
if err != nil {
return fmt.Errorf("feishu send image: %w", err)
}
@@ -49,8 +53,11 @@ func (c *Channel) sendFile(ctx context.Context, chatID, receiveIDType, fileKey,
if msgType == "" {
msgType = "file"
}
content := fmt.Sprintf(`{"file_key":"%s"}`, fileKey)
_, err := c.client.SendMessage(ctx, receiveIDType, chatID, msgType, content)
contentBytes, err := json.Marshal(map[string]string{"file_key": fileKey})
if err != nil {
return fmt.Errorf("marshal file content: %w", err)
}
_, err = c.client.SendMessage(ctx, receiveIDType, chatID, msgType, string(contentBytes))
if err != nil {
return fmt.Errorf("feishu send file: %w", err)
}
+11 -7
View File
@@ -137,7 +137,7 @@ func (rc *StatusReactionController) SetStatus(ctx context.Context, status string
emoji := resolveReactionEmoji(rc.lastStatus, nil)
if emoji != "" {
rc.applyReaction(ctx, emoji)
rc.applyReaction(context.Background(), emoji)
}
})
}
@@ -180,7 +180,7 @@ func (rc *StatusReactionController) applyReaction(ctx context.Context, emoji str
}
// resetStallTimer resets the stall detection timer (must hold mu lock).
func (rc *StatusReactionController) resetStallTimer(ctx context.Context) {
func (rc *StatusReactionController) resetStallTimer(_ context.Context) {
rc.cancelStall()
rc.stallTimer = time.AfterFunc(stallSoftMs, func() {
@@ -193,7 +193,7 @@ func (rc *StatusReactionController) resetStallTimer(ctx context.Context) {
emoji := resolveReactionEmoji("stallSoft", nil)
if emoji != "" {
rc.applyReaction(ctx, emoji)
rc.applyReaction(context.Background(), emoji)
}
// Schedule hard stall
@@ -207,7 +207,7 @@ func (rc *StatusReactionController) resetStallTimer(ctx context.Context) {
emoji := resolveReactionEmoji("stallHard", nil)
if emoji != "" {
rc.applyReaction(ctx, emoji)
rc.applyReaction(context.Background(), emoji)
}
})
})
@@ -258,7 +258,10 @@ func (c *Channel) OnReactionEvent(ctx context.Context, chatID string, messageID
// Key by messageID so concurrent runs in the same chat don't clash.
key := fmt.Sprintf("%s:%s", chatID, messageID)
val, _ := c.reactions.LoadOrStore(key, newStatusReactionController(c.bot, id, msgID))
rc := val.(*StatusReactionController)
rc, ok := val.(*StatusReactionController)
if !ok {
return nil
}
rc.SetStatus(ctx, status)
@@ -285,8 +288,9 @@ func (c *Channel) ClearReaction(ctx context.Context, chatID string, messageID st
// Stop and remove controller if exists
key := fmt.Sprintf("%s:%s", chatID, messageID)
if val, ok := c.reactions.LoadAndDelete(key); ok {
rc := val.(*StatusReactionController)
rc.Stop()
if rc, ok := val.(*StatusReactionController); ok {
rc.Stop()
}
}
// Clear reaction on the message
+3 -1
View File
@@ -134,7 +134,9 @@ func (c *Channel) Stop(_ context.Context) error {
slog.Info("stopping zalo_personal channel")
c.stopOnce.Do(func() { close(c.stopCh) })
c.typingCtrls.Range(func(key, val any) bool {
val.(*typing.Controller).Stop()
if ctrl, ok := val.(*typing.Controller); ok {
ctrl.Stop()
}
c.typingCtrls.Delete(key)
return true
})
+3 -1
View File
@@ -147,7 +147,9 @@ func (c *Channel) startTyping(threadID string, threadType protocol.ThreadType) {
},
})
if prev, ok := c.typingCtrls.Load(threadID); ok {
prev.(*typing.Controller).Stop()
if ctrl, ok := prev.(*typing.Controller); ok {
ctrl.Stop()
}
}
c.typingCtrls.Store(threadID, ctrl)
ctrl.Start()
+8
View File
@@ -187,6 +187,10 @@ func (c *Channel) processUpdate(update zaloUpdate) {
func (c *Channel) handleTextMessage(msg *zaloMessage) {
senderID := msg.From.ID
if senderID == "" {
slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID)
return
}
chatID := msg.Chat.ID
if chatID == "" {
chatID = senderID
@@ -218,6 +222,10 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) {
func (c *Channel) handleImageMessage(msg *zaloMessage) {
senderID := msg.From.ID
if senderID == "" {
slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID)
return
}
chatID := msg.Chat.ID
if chatID == "" {
chatID = senderID