forked from huabeitech/agent-desk
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(channels): add LINE, Viber, and Meta Threads messaging channels #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package third | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "agent-desk/internal/services" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // LinePostWebhook receives incoming webhook events from the LINE Platform. | ||
| func LinePostWebhook(ctx *gin.Context) { | ||
| channelID := strings.TrimSpace(ctx.Param("channel_id")) | ||
| if channelID == "" { | ||
| channelID = strings.TrimSpace(ctx.Query("channel_id")) | ||
| } | ||
|
|
||
| signature := ctx.GetHeader("X-Line-Signature") | ||
|
|
||
| bodyBytes, err := io.ReadAll(ctx.Request.Body) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"}) | ||
| return | ||
| } | ||
| ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) | ||
|
|
||
| if err := services.LineInboundService.HandleWebhook(ctx.Request.Context(), channelID, signature, bodyBytes); err != nil { | ||
| ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| ctx.JSON(http.StatusOK, gin.H{"ok": true}) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package third | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "agent-desk/internal/pkg/enums" | ||
| "agent-desk/internal/services" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // ThreadsGetWebhook handles Meta webhook verification (hub.challenge). | ||
| func ThreadsGetWebhook(ctx *gin.Context) { | ||
| mode := strings.TrimSpace(ctx.Query("hub.mode")) | ||
| token := strings.TrimSpace(ctx.Query("hub.verify_token")) | ||
| challenge := strings.TrimSpace(ctx.Query("hub.challenge")) | ||
|
|
||
| channelID := strings.TrimSpace(ctx.Param("channel_id")) | ||
| if channelID == "" { | ||
| channelID = strings.TrimSpace(ctx.Query("channel_id")) | ||
| } | ||
|
|
||
| if mode == "subscribe" { | ||
| if channelID != "" { | ||
| channel := services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeThreads, enums.StatusOk) | ||
| if channel != nil { | ||
| if cfg, err := services.ChannelService.ParseThreadsChannelConfig(channel.ConfigJSON); err == nil && cfg != nil { | ||
| if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token { | ||
| ctx.String(http.StatusForbidden, "Verification token mismatch") | ||
| return | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| ctx.String(http.StatusOK, challenge) | ||
| return | ||
| } | ||
|
|
||
| ctx.String(http.StatusBadRequest, "Invalid verification request") | ||
| } | ||
|
|
||
| // ThreadsPostWebhook receives incoming webhook events from Meta Threads. | ||
| func ThreadsPostWebhook(ctx *gin.Context) { | ||
| channelID := strings.TrimSpace(ctx.Param("channel_id")) | ||
| if channelID == "" { | ||
| channelID = strings.TrimSpace(ctx.Query("channel_id")) | ||
| } | ||
|
|
||
| sigHeader := ctx.GetHeader("X-Hub-Signature-256") | ||
| if sigHeader == "" { | ||
| sigHeader = ctx.GetHeader("X-Hub-Signature") | ||
| } | ||
|
|
||
| bodyBytes, err := io.ReadAll(ctx.Request.Body) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"}) | ||
| return | ||
| } | ||
| ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) | ||
|
|
||
| if err := services.ThreadsInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil { | ||
| ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "EVENT_RECEIVED"}) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package third | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "agent-desk/internal/services" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // ViberPostWebhook receives incoming callbacks from Viber. | ||
| // | ||
| // For a conversation_started callback with a welcome message configured, | ||
| // the welcome message JSON is written to the response body as required | ||
| // by the Viber API. | ||
| func ViberPostWebhook(ctx *gin.Context) { | ||
| channelID := strings.TrimSpace(ctx.Param("channel_id")) | ||
| if channelID == "" { | ||
| channelID = strings.TrimSpace(ctx.Query("channel_id")) | ||
| } | ||
|
|
||
| signature := ctx.GetHeader("X-Viber-Content-Signature") | ||
|
|
||
| bodyBytes, err := io.ReadAll(ctx.Request.Body) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"}) | ||
| return | ||
| } | ||
| ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) | ||
|
|
||
| responseBody, err := services.ViberInboundService.HandleWebhook(ctx.Request.Context(), channelID, signature, bodyBytes) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| if responseBody != "" { | ||
| ctx.Data(http.StatusOK, "application/json", []byte(responseBody)) | ||
| return | ||
| } | ||
|
|
||
| ctx.JSON(http.StatusOK, gin.H{"ok": true}) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| package line | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "crypto/hmac" | ||
| "crypto/sha256" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| const defaultBaseURL = "https://api.line.me" | ||
|
|
||
| type Client struct { | ||
| channelAccessToken string | ||
| baseURL string | ||
| httpClient *http.Client | ||
| } | ||
|
|
||
| func NewClient(channelAccessToken string) *Client { | ||
| return &Client{ | ||
| channelAccessToken: strings.TrimSpace(channelAccessToken), | ||
| baseURL: defaultBaseURL, | ||
| httpClient: &http.Client{Timeout: 15 * time.Second}, | ||
| } | ||
| } | ||
|
|
||
| func (c *Client) SetBaseURL(url string) { | ||
| if strings.TrimSpace(url) != "" { | ||
| c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/") | ||
| } | ||
| } | ||
|
|
||
| // VerifyWebhookSignature validates the x-line-signature header value. | ||
| // The signature is HMAC-SHA256 of the raw body keyed by the channel secret, | ||
| // encoded as base64. | ||
| func VerifyWebhookSignature(channelSecret string, signature string, payload []byte) bool { | ||
| secret := strings.TrimSpace(channelSecret) | ||
| sig := strings.TrimSpace(signature) | ||
| if secret == "" || sig == "" { | ||
| return false | ||
| } | ||
| mac := hmac.New(sha256.New, []byte(secret)) | ||
| mac.Write(payload) | ||
| expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) | ||
| return hmac.Equal([]byte(expected), []byte(sig)) | ||
| } | ||
|
|
||
| // PushMessage sends a push message to a user via the LINE Messaging API. | ||
| func (c *Client) PushMessage(ctx context.Context, req PushMessageRequest) (*PushMessageResponse, error) { | ||
| if strings.TrimSpace(req.To) == "" { | ||
| return nil, fmt.Errorf("line recipient (to) is required") | ||
| } | ||
| if len(req.Messages) == 0 { | ||
| return nil, fmt.Errorf("line message list is required") | ||
| } | ||
|
|
||
| var resp PushMessageResponse | ||
| if err := c.doRequest(ctx, "/v2/bot/message/push", req, &resp); err != nil { | ||
| return nil, err | ||
| } | ||
| return &resp, nil | ||
| } | ||
|
|
||
| func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error { | ||
| if c.channelAccessToken == "" { | ||
| return fmt.Errorf("line channel access token is required") | ||
| } | ||
|
|
||
| endpoint := c.baseURL + path | ||
|
|
||
| var bodyReader io.Reader | ||
| if payload != nil { | ||
| bodyBytes, err := json.Marshal(payload) | ||
| if err != nil { | ||
| return fmt.Errorf("marshal line request failed: %w", err) | ||
| } | ||
| bodyReader = bytes.NewBuffer(bodyBytes) | ||
| } | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader) | ||
| if err != nil { | ||
| return fmt.Errorf("create line request failed: %w", err) | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+c.channelAccessToken) | ||
| if payload != nil { | ||
| req.Header.Set("Content-Type", "application/json") | ||
| } | ||
|
|
||
| res, err := c.httpClient.Do(req) | ||
| if err != nil { | ||
| return fmt.Errorf("line http request failed: %w", err) | ||
| } | ||
| defer res.Body.Close() | ||
|
|
||
| bodyBytes, err := io.ReadAll(res.Body) | ||
| if err != nil { | ||
| return fmt.Errorf("read line response failed: %w", err) | ||
| } | ||
|
|
||
| if res.StatusCode < 200 || res.StatusCode >= 300 { | ||
| return fmt.Errorf("line api error (%d): %s", res.StatusCode, string(bodyBytes)) | ||
| } | ||
|
|
||
| if result != nil && len(bodyBytes) > 0 { | ||
| if err := json.Unmarshal(bodyBytes, result); err != nil { | ||
| return fmt.Errorf("unmarshal line response failed: %w (body: %s)", err, string(bodyBytes)) | ||
| } | ||
| } | ||
| return nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package line | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/hmac" | ||
| "crypto/sha256" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestLinePushMessage(t *testing.T) { | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| if r.URL.Path != "/v2/bot/message/push" { | ||
| t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path) | ||
| } | ||
| if r.Header.Get("Authorization") != "Bearer test_token" { | ||
| t.Errorf("expected Bearer test_token, got %s", r.Header.Get("Authorization")) | ||
| } | ||
| var req PushMessageRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| t.Errorf("decode request failed: %v", err) | ||
| } | ||
| if req.To != "U4af4980629" { | ||
| t.Errorf("expected to U4af4980629, got %s", req.To) | ||
| } | ||
| if len(req.Messages) != 1 || req.Messages[0].Text != "hello" { | ||
| t.Errorf("unexpected messages: %+v", req.Messages) | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
| w.Write([]byte(`{"sentMessages":[{"id":"4612309"}]}`)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| client := NewClient("test_token") | ||
| client.SetBaseURL(server.URL) | ||
|
|
||
| resp, err := client.PushMessage(context.Background(), PushMessageRequest{ | ||
| To: "U4af4980629", | ||
| Messages: []MessageObject{{Type: "text", Text: "hello"}}, | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("PushMessage failed: %v", err) | ||
| } | ||
| if len(resp.SentMessages) != 1 || resp.SentMessages[0].ID != "4612309" { | ||
| t.Errorf("unexpected response: %+v", resp) | ||
| } | ||
| } | ||
|
|
||
| func TestLineVerifyWebhookSignature(t *testing.T) { | ||
| const secret = "8c570fa6dd201bb328f1c1eac23a96d8" | ||
| body := []byte(`{"destination":"U8e742f61d673b39c7fff3cecb7536ef0","events":[]}`) | ||
|
|
||
| mac := hmac.New(sha256.New, []byte(secret)) | ||
| mac.Write(body) | ||
| valid := base64.StdEncoding.EncodeToString(mac.Sum(nil)) | ||
|
|
||
| if !VerifyWebhookSignature(secret, valid, body) { | ||
| t.Errorf("expected valid signature to verify") | ||
| } | ||
| if VerifyWebhookSignature(secret, "bad-signature", body) { | ||
| t.Errorf("expected invalid signature to fail") | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
channelIDis empty, the verification token check is skipped entirely, and the challenge is returned with a200 OKstatus. This allows anyone to verify a subscription without a valid token if they call the endpoint without achannel_idparameter.To fix this security issue, you should fall back to the default Threads channel when
channelIDis empty (similar to how it is handled inThreadsPostWebhook), and enforce token verification.