feat(channels/telegram): Telegram 渠道插件(Bot API 长轮询/Webhook + file_id 媒体 + 主动推送强)
This commit is contained in:
1 parent
0b036645bb
commit
f9f5a78a02
12 files changed
+1983
No files matched your search
@@ -0,0 +1,205 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// botResponse is the shared envelope Telegram returns for every method: ok plus
|
||||
// either result or the error description/code.
|
||||
type botResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Description string `json:"description"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
}
|
||||
|
||||
// doJSON posts a JSON payload to a Bot API method and decodes the envelope. The
|
||||
// caller is responsible for decoding Result; a non-OK reply is returned as
|
||||
// ErrBadResponse wrapped with the API description.
|
||||
func (a *Adapter) doJSON(ctx context.Context, method string, payload any) (*botResponse, error) {
|
||||
var buf bytes.Buffer
|
||||
if payload != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||||
return nil, fmt.Errorf("telegram: encode %s request: %w", method, err)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(method), &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram: build %s request: %w", method, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram: %s call: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
desc := strings.TrimSpace(string(body))
|
||||
if desc != "" {
|
||||
return nil, fmt.Errorf("telegram: %s: status %d: %s: %w", method, resp.StatusCode, desc, ErrBadResponse)
|
||||
}
|
||||
return nil, fmt.Errorf("telegram: %s: status %d: %w", method, resp.StatusCode, ErrBadResponse)
|
||||
}
|
||||
|
||||
var out botResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("telegram: decode %s response: %w", method, err)
|
||||
}
|
||||
if !out.OK {
|
||||
return nil, fmt.Errorf("telegram: %s: %w", method, ErrBadResponse)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// getMe verifies the token and returns the bot's username.
|
||||
func (a *Adapter) getMe(ctx context.Context) (string, error) {
|
||||
out, err := a.doJSON(ctx, "getMe", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var me struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Result, &me); err != nil {
|
||||
return "", fmt.Errorf("telegram: getMe result: %w", err)
|
||||
}
|
||||
return me.Username, nil
|
||||
}
|
||||
|
||||
// setWebhook registers the webhook URL and disables getUpdates polling.
|
||||
func (a *Adapter) setWebhook(ctx context.Context, url string) error {
|
||||
_, err := a.doJSON(ctx, "setWebhook", map[string]any{"url": url})
|
||||
return err
|
||||
}
|
||||
|
||||
// getUpdates performs a long-poll for updates at the current offset. It returns
|
||||
// a list of updates; the caller advances a.offset after acknowledging them.
|
||||
func (a *Adapter) getUpdates(ctx context.Context) ([]telegramUpdate, error) {
|
||||
a.mu.RLock()
|
||||
offset := a.offset
|
||||
a.mu.RUnlock()
|
||||
|
||||
payload := map[string]any{
|
||||
"offset": offset,
|
||||
"limit": maxUpdatesPerPoll,
|
||||
"timeout": longPollTimeoutSec,
|
||||
}
|
||||
out, err := a.doJSON(ctx, "getUpdates", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []telegramUpdate
|
||||
if err := json.Unmarshal(out.Result, &updates); err != nil {
|
||||
return nil, fmt.Errorf("telegram: getUpdates result: %w", err)
|
||||
}
|
||||
if updates == nil {
|
||||
updates = []telegramUpdate{}
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// sendMessage sends a text message and returns the new message id.
|
||||
func (a *Adapter) sendMessage(ctx context.Context, chatID, text string, replyTo int64, parseMode string) (int64, error) {
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
}
|
||||
if replyTo != 0 {
|
||||
payload["reply_to_message_id"] = replyTo
|
||||
}
|
||||
if parseMode != "" {
|
||||
payload["parse_mode"] = parseMode
|
||||
}
|
||||
return a.sendPayload(ctx, "sendMessage", payload)
|
||||
}
|
||||
|
||||
// sendPhoto sends an existing photo by file_id and returns the new message id.
|
||||
func (a *Adapter) sendPhoto(ctx context.Context, chatID, fileID string, replyTo int64, caption string) (int64, error) {
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"photo": fileID,
|
||||
}
|
||||
if caption != "" {
|
||||
payload["caption"] = caption
|
||||
payload["parse_mode"] = "Markdown"
|
||||
}
|
||||
if replyTo != 0 {
|
||||
payload["reply_to_message_id"] = replyTo
|
||||
}
|
||||
return a.sendPayload(ctx, "sendPhoto", payload)
|
||||
}
|
||||
|
||||
// sendVoice sends an existing voice clip by file_id and returns the new id.
|
||||
func (a *Adapter) sendVoice(ctx context.Context, chatID, fileID string, replyTo int64, caption string) (int64, error) {
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"voice": fileID,
|
||||
}
|
||||
if caption != "" {
|
||||
payload["caption"] = caption
|
||||
payload["parse_mode"] = "Markdown"
|
||||
}
|
||||
if replyTo != 0 {
|
||||
payload["reply_to_message_id"] = replyTo
|
||||
}
|
||||
return a.sendPayload(ctx, "sendVoice", payload)
|
||||
}
|
||||
|
||||
// sendDocument sends an existing document by file_id and returns the new id.
|
||||
func (a *Adapter) sendDocument(ctx context.Context, chatID, fileID, fileName string, replyTo int64, caption string) (int64, error) {
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"document": fileID,
|
||||
}
|
||||
if fileName != "" {
|
||||
payload["file_name"] = fileName
|
||||
}
|
||||
if caption != "" {
|
||||
payload["caption"] = caption
|
||||
payload["parse_mode"] = "Markdown"
|
||||
}
|
||||
if replyTo != 0 {
|
||||
payload["reply_to_message_id"] = replyTo
|
||||
}
|
||||
return a.sendPayload(ctx, "sendDocument", payload)
|
||||
}
|
||||
|
||||
// sendPayload posts a media/message payload and returns the acknowledged
|
||||
// message id from the Bot API result.
|
||||
func (a *Adapter) sendPayload(ctx context.Context, method string, payload map[string]any) (int64, error) {
|
||||
out, err := a.doJSON(ctx, method, payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var res struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Result, &res); err != nil {
|
||||
return 0, fmt.Errorf("telegram: %s result: %w", method, err)
|
||||
}
|
||||
return res.MessageID, nil
|
||||
}
|
||||
|
||||
// parseReplyToID converts a reply context identifier to a Telegram message id.
|
||||
// A non-numeric or empty value yields 0 (no reply).
|
||||
func parseReplyToID(s string) int64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in new issue
Block a user