Files
ragflow/internal/channels/telegram.go
Hz_ d69ace8c94 feat(go-channels): add Telegram and WeCom channels (#17564)
## Summary

- Add Telegram long-polling channel support.
- Add WeCom webhook and WebSocket channel support with tests.

Related to #17520
2026-07-31 10:49:54 +08:00

413 lines
10 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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 channels
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"ragflow/internal/channels/core"
)
const (
defaultTelegramAPIBaseURL = "https://api.telegram.org"
telegramPollTimeout = 30 * time.Second
telegramHTTPTimeout = 40 * time.Second
telegramReconnectDelay = 3 * time.Second
)
type telegramAccount struct {
AccountID string
Token string
APIBaseURL string
}
type telegramChannel struct {
account telegramAccount
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
handler core.MessageHandler
client *http.Client
offset int64
}
type telegramAPIResponse struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
Description string `json:"description"`
}
type telegramTransportError struct {
method string
err error
}
func (e *telegramTransportError) Error() string {
return fmt.Sprintf("Telegram API %s transport request failed", e.method)
}
func (e *telegramTransportError) Unwrap() error {
return e.err
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *telegramMessage `json:"message"`
EditedMessage *telegramMessage `json:"edited_message"`
ChannelPost *telegramMessage `json:"channel_post"`
EditedChannelPost *telegramMessage `json:"edited_channel_post"`
Raw map[string]any
}
type telegramMessage struct {
MessageID int64 `json:"message_id"`
From *telegramUser `json:"from"`
Chat telegramChat `json:"chat"`
Text string `json:"text"`
Caption string `json:"caption"`
}
type telegramUser struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
}
type telegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"`
}
// newTelegramChannel creates a Telegram channel with default API settings.
func newTelegramChannel(account telegramAccount) *telegramChannel {
account.Token = strings.TrimSpace(account.Token)
account.APIBaseURL = strings.TrimSpace(account.APIBaseURL)
if account.APIBaseURL == "" {
account.APIBaseURL = defaultTelegramAPIBaseURL
}
return &telegramChannel{
account: account,
client: &http.Client{Timeout: telegramHTTPTimeout},
}
}
// newTelegramChannelFromConfig builds a Telegram channel from chat_channel.config.credential.
func newTelegramChannelFromConfig(accountID string, cfg map[string]any) (*telegramChannel, error) {
token := strings.TrimSpace(fmt.Sprint(cfg["token"]))
if token == "" || token == "<nil>" {
return nil, fmt.Errorf("telegram account %q is missing token", accountID)
}
// APIBaseURL is intentionally optional and is useful for Bot API-compatible
// proxies and tests. The Python implementation defaults to api.telegram.org.
baseURL := firstString(cfg, "api_base_url", "base_url")
return newTelegramChannel(telegramAccount{
AccountID: accountID,
Token: token,
APIBaseURL: baseURL,
}), nil
}
// ChannelID returns the platform identifier used by the chat-channel runtime.
func (c *telegramChannel) ChannelID() string {
return "telegram"
}
// AccountID returns the chat_channel.id bound to this Telegram runtime instance.
func (c *telegramChannel) AccountID() string {
return c.account.AccountID
}
// SetMessageHandler installs the RAGFlow bridge invoked for inbound messages.
func (c *telegramChannel) SetMessageHandler(handler core.MessageHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.handler = handler
}
// Start starts Telegram long polling in the background.
func (c *telegramChannel) Start(ctx context.Context) error {
c.mu.Lock()
if c.cancel != nil {
c.mu.Unlock()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
c.cancel = cancel
c.done = done
c.offset = 0
c.mu.Unlock()
go func() {
defer close(done)
c.run(runCtx)
c.mu.Lock()
if c.done == done {
c.cancel = nil
}
c.mu.Unlock()
}()
return nil
}
// Stop cancels Telegram long polling and waits for the poll request to finish.
func (c *telegramChannel) Stop(ctx context.Context) error {
c.mu.Lock()
cancel := c.cancel
done := c.done
c.mu.Unlock()
if cancel == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Send sends a Telegram message and optionally replies to an existing message.
// Ai -> user
func (c *telegramChannel) Send(ctx context.Context, msg core.OutgoingMessage) error {
chatID, err := strconv.ParseInt(strings.TrimSpace(msg.ChatID), 10, 64)
if err != nil {
log.Printf("[telegram:%s] invalid chat_id %q: %v", c.account.AccountID, msg.ChatID, err)
return nil
}
payload := map[string]any{
"chat_id": chatID,
"text": msg.Text,
}
if replyID := strings.TrimSpace(msg.ReplyToMessageID); replyID != "" {
if messageID, parseErr := strconv.ParseInt(replyID, 10, 64); parseErr == nil {
payload["reply_parameters"] = map[string]any{
"message_id": messageID,
"allow_sending_without_reply": true,
}
}
}
if err := c.callAPI(ctx, "sendMessage", payload, nil); err != nil {
log.Printf("[telegram:%s] send failed: %v", c.account.AccountID, err)
}
return nil
}
// run drops queued updates and consumes Telegram updates until stopped.
func (c *telegramChannel) run(ctx context.Context) {
for ctx.Err() == nil {
err := c.callAPI(ctx, "deleteWebhook", map[string]any{
"drop_pending_updates": true,
}, nil)
if err == nil {
break
}
log.Printf("[telegram:%s] failed to start polling: %v", c.account.AccountID, err)
if !waitForTelegramRetry(ctx) {
return
}
}
if ctx.Err() != nil {
return
}
for ctx.Err() == nil {
updates, err := c.getUpdates(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("[telegram:%s] polling failed: %v", c.account.AccountID, err)
if !waitForTelegramRetry(ctx) {
return
}
continue
}
for _, update := range updates {
if update.UpdateID >= c.offset {
c.offset = update.UpdateID + 1
}
c.handleUpdate(ctx, update)
}
}
}
// getUpdates performs one Telegram long-poll request and decodes raw updates.
func (c *telegramChannel) getUpdates(ctx context.Context) ([]telegramUpdate, error) {
params := map[string]any{
"offset": c.offset,
"timeout": int(telegramPollTimeout / time.Second),
}
var rawUpdates []json.RawMessage
if err := c.callAPI(ctx, "getUpdates", params, &rawUpdates); err != nil {
return nil, err
}
updates := make([]telegramUpdate, 0, len(rawUpdates))
for _, raw := range rawUpdates {
var update telegramUpdate
if err := json.Unmarshal(raw, &update); err != nil {
log.Printf("[telegram:%s] ignored invalid update: %v", c.account.AccountID, err)
continue
}
_ = json.Unmarshal(raw, &update.Raw)
updates = append(updates, update)
}
return updates, nil
}
// handleUpdate maps Telegram's effective message into the common channel model.
// Telegram -> AI
func (c *telegramChannel) handleUpdate(ctx context.Context, update telegramUpdate) {
msg := update.effectiveMessage()
if msg == nil {
return
}
if msg.From == nil && msg != update.ChannelPost && msg != update.EditedChannelPost {
return
}
if msg.From != nil && msg.From.IsBot {
return
}
text := msg.Text
if text == "" {
text = msg.Caption
}
senderID := strconv.FormatInt(msg.Chat.ID, 10)
if msg.From != nil {
senderID = strconv.FormatInt(msg.From.ID, 10)
}
incoming := core.IncomingMessage{
Channel: c.ChannelID(),
AccountID: c.account.AccountID,
ChatID: strconv.FormatInt(msg.Chat.ID, 10),
ChatType: telegramChatType(msg.Chat.Type),
MessageID: strconv.FormatInt(msg.MessageID, 10),
SenderID: senderID,
Text: text,
Raw: update.Raw,
}
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler == nil {
return
}
if err := handler(ctx, incoming); err != nil {
log.Printf("[telegram:%s] message handler error: %v", c.account.AccountID, err)
}
}
func (u telegramUpdate) effectiveMessage() *telegramMessage {
if u.Message != nil {
return u.Message
}
if u.EditedMessage != nil {
return u.EditedMessage
}
if u.ChannelPost != nil {
return u.ChannelPost
}
return u.EditedChannelPost
}
func telegramChatType(value string) string {
switch value {
case "private":
return "p2p"
case "group", "supergroup":
return "group"
case "channel":
return "channel"
default:
if value == "" {
return "unknown"
}
return value
}
}
// callAPI invokes a Telegram Bot API method using the configured token.
func (c *telegramChannel) callAPI(ctx context.Context, method string, params map[string]any, out any) error {
payload, err := json.Marshal(params)
if err != nil {
return err
}
baseURL := strings.TrimRight(c.account.APIBaseURL, "/")
endpoint := baseURL + "/bot" + url.PathEscape(c.account.Token) + "/" + method
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return &telegramTransportError{method: method, err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return err
}
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("Telegram API %s returned status %d: %s", method, resp.StatusCode, string(body))
}
var response telegramAPIResponse
if err := json.Unmarshal(body, &response); err != nil {
return fmt.Errorf("Telegram API %s returned invalid JSON: %w", method, err)
}
if !response.OK {
return fmt.Errorf("Telegram API %s failed: %s", method, response.Description)
}
if out != nil && len(response.Result) > 0 {
if err := json.Unmarshal(response.Result, out); err != nil {
return fmt.Errorf("Telegram API %s returned invalid result: %w", method, err)
}
}
return nil
}
func waitForTelegramRetry(ctx context.Context) bool {
timer := time.NewTimer(telegramReconnectDelay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}