WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
package telegram
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// Compile-time checks.
var (
_ im.Adapter = (*Adapter)(nil)
_ im.StreamSender = (*Adapter)(nil)
_ im.FileDownloader = (*Adapter)(nil)
)
// Adapter implements im.Adapter for Telegram Bot API.
type Adapter struct {
botToken string
secretToken string // optional X-Telegram-Bot-Api-Secret-Token for webhook verification
client *LongConnClient
}
// NewWebhookAdapter creates a Telegram adapter for webhook mode.
func NewWebhookAdapter(botToken, secretToken string) *Adapter {
startStreamReaper()
return &Adapter{
botToken: botToken,
secretToken: secretToken,
}
}
// NewAdapter creates a Telegram adapter backed by a long-polling client.
func NewAdapter(client *LongConnClient, botToken string) *Adapter {
startStreamReaper()
return &Adapter{
botToken: botToken,
client: client,
}
}
func (a *Adapter) Platform() im.Platform {
return im.PlatformTelegram
}
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
return false // Telegram does not require URL verification challenges.
}
func (a *Adapter) VerifyCallback(c *gin.Context) error {
if a.secretToken == "" {
return nil
}
token := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
if subtle.ConstantTimeCompare([]byte(token), []byte(a.secretToken)) != 1 {
return fmt.Errorf("invalid secret token")
}
return nil
}
// telegramUpdate represents an incoming Telegram update (subset of fields).
type telegramUpdate struct {
UpdateID int `json:"update_id"`
Message *telegramMsg `json:"message"`
}
type telegramMsg struct {
MessageID int `json:"message_id"`
MessageThreadID int `json:"message_thread_id"`
From *telegramUser `json:"from"`
Chat telegramChat `json:"chat"`
Text string `json:"text"`
Document *telegramDoc `json:"document"`
Photo []telegramPhoto `json:"photo"`
}
type telegramUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
}
type telegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"` // "private", "group", "supergroup", "channel"
}
type telegramDoc struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
}
type telegramPhoto struct {
FileID string `json:"file_id"`
FileSize int `json:"file_size"`
Width int `json:"width"`
Height int `json:"height"`
}
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
var update telegramUpdate
if err := json.Unmarshal(bodyBytes, &update); err != nil {
return nil, fmt.Errorf("parse update: %w", err)
}
return parseUpdate(&update), nil
}
func parseUpdate(update *telegramUpdate) *im.IncomingMessage {
if update.Message == nil {
return nil
}
return parseTelegramMessage(update.Message)
}
func parseTelegramMessage(msg *telegramMsg) *im.IncomingMessage {
if msg == nil {
return nil
}
chatType := im.ChatTypeDirect
chatID := ""
if msg.Chat.Type == "group" || msg.Chat.Type == "supergroup" {
chatType = im.ChatTypeGroup
chatID = fmt.Sprintf("%d", msg.Chat.ID)
}
userID := ""
userName := ""
if msg.From != nil {
userID = fmt.Sprintf("%d", msg.From.ID)
userName = strings.TrimSpace(msg.From.FirstName + " " + msg.From.LastName)
if userName == "" {
userName = msg.From.Username
}
}
threadID := ""
if msg.MessageThreadID != 0 {
threadID = fmt.Sprintf("%d", msg.MessageThreadID)
}
incoming := &im.IncomingMessage{
Platform: im.PlatformTelegram,
UserID: userID,
UserName: userName,
ChatID: chatID,
ChatType: chatType,
MessageID: fmt.Sprintf("%d", msg.MessageID),
ThreadID: threadID,
MessageType: im.MessageTypeText,
Content: msg.Text,
}
// For group messages, strip bot mention prefix (e.g., "/command@botname text" -> "text")
if chatType == im.ChatTypeGroup {
content := strings.TrimSpace(msg.Text)
// Remove @bot mentions
if idx := strings.Index(content, " "); idx > 0 && strings.Contains(content[:idx], "@") {
content = strings.TrimSpace(content[idx+1:])
}
incoming.Content = content
}
// Handle document
if msg.Document != nil {
incoming.MessageType = im.MessageTypeFile
incoming.FileKey = msg.Document.FileID
incoming.FileName = msg.Document.FileName
incoming.FileSize = msg.Document.FileSize
}
// Handle photo (use the largest photo)
if len(msg.Photo) > 0 {
largest := msg.Photo[len(msg.Photo)-1]
incoming.MessageType = im.MessageTypeImage
incoming.FileKey = largest.FileID
incoming.FileName = "photo.jpg"
incoming.FileSize = int64(largest.FileSize)
}
return incoming
}
// resolveChatID returns ChatID if set, otherwise falls back to UserID (for direct messages).
func resolveChatID(incoming *im.IncomingMessage) string {
if incoming.ChatID != "" {
return incoming.ChatID
}
return incoming.UserID
}
// ── Send reply ──
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
chatID := resolveChatID(incoming)
text := im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal)
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "Markdown",
}
if incoming.ThreadID != "" {
if tid, err := strconv.Atoi(incoming.ThreadID); err == nil {
body["message_thread_id"] = tid
}
}
return a.callAPI(ctx, "sendMessage", body)
}
func (a *Adapter) sendMessage(ctx context.Context, chatID, text, replyToMessageID string) error {
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "Markdown",
}
if replyToMessageID != "" {
body["reply_to_message_id"] = replyToMessageID
}
return a.callAPI(ctx, "sendMessage", body)
}
func (a *Adapter) editMessage(ctx context.Context, chatID, messageID, text, parseMode string) error {
body := map[string]interface{}{
"chat_id": chatID,
"message_id": json.Number(messageID),
"text": text,
}
if parseMode != "" {
body["parse_mode"] = parseMode
}
return a.callAPI(ctx, "editMessageText", body)
}
// httpClient is a shared HTTP client with a reasonable timeout for Telegram API calls.
var httpClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 15 * time.Second,
MaxRedirects: 5,
})
// callAPI calls the Telegram Bot API, discarding the result.
func (a *Adapter) callAPI(ctx context.Context, method string, body interface{}) error {
return a.callAPIWithResult(ctx, method, body, nil)
}
// callAPIWithResult calls the Telegram Bot API and optionally decodes the result field.
func (a *Adapter) callAPIWithResult(ctx context.Context, method string, body interface{}, result interface{}) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", a.botToken, method)
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
var apiResp struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if !apiResp.OK {
return fmt.Errorf("telegram API %s failed: %s", method, string(apiResp.Result))
}
if result != nil {
if err := json.Unmarshal(apiResp.Result, result); err != nil {
return fmt.Errorf("decode result: %w", err)
}
}
return nil
}
// ── StreamSender implementation (edit message in-place) ──
// minEditInterval is the minimum time between consecutive editMessageText calls
// to avoid hitting Telegram's rate limit (~30 msg/sec global, ~20 edit/min per chat).
const minEditInterval = 500 * time.Millisecond
type streamState struct {
mu sync.Mutex
content strings.Builder
msgID string // Telegram message ID of the "thinking" message
chatID string
lastEdit time.Time // last successful editMessageText timestamp
createdAt time.Time // for orphan stream detection
}
const (
streamOrphanTTL = 5 * time.Minute
streamReaperInterval = 1 * time.Minute
)
var (
streamsMu sync.Mutex
streams = map[string]*streamState{}
startReaperOnce sync.Once
reaperStopCh = make(chan struct{})
)
// startStreamReaper starts a background goroutine (once) that periodically
// removes orphaned stream entries. This prevents memory leaks when EndStream
// is never called due to panics or pipeline errors.
func startStreamReaper() {
startReaperOnce.Do(func() {
go func() {
ticker := time.NewTicker(streamReaperInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cutoff := time.Now().Add(-streamOrphanTTL)
streamsMu.Lock()
for id, state := range streams {
if state.createdAt.Before(cutoff) {
delete(streams, id)
}
}
streamsMu.Unlock()
case <-reaperStopCh:
return
}
}
}()
})
}
func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) (string, error) {
chatID := resolveChatID(incoming)
// Send initial "thinking" message
body := map[string]interface{}{
"chat_id": chatID,
"text": "正在思考...",
}
if incoming.ThreadID != "" {
if tid, err := strconv.Atoi(incoming.ThreadID); err == nil {
body["message_thread_id"] = tid
}
}
var sentMsg struct {
MessageID int `json:"message_id"`
}
if err := a.callAPIWithResult(ctx, "sendMessage", body, &sentMsg); err != nil {
return "", fmt.Errorf("telegram start stream: %w", err)
}
msgID := fmt.Sprintf("%d", sentMsg.MessageID)
streamID := fmt.Sprintf("%s:%s", chatID, msgID)
streamsMu.Lock()
streams[streamID] = &streamState{
msgID: msgID,
chatID: chatID,
createdAt: time.Now(),
}
streamsMu.Unlock()
logger.Infof(ctx, "[Telegram] Streaming started: stream_id=%s", streamID)
return streamID, nil
}
func (a *Adapter) UpdateStreamContent(ctx context.Context, incoming *im.IncomingMessage, streamID string, fullContent string) error {
if fullContent == "" {
return nil
}
streamsMu.Lock()
state, ok := streams[streamID]
streamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
if time.Since(state.lastEdit) < minEditInterval {
state.content.Reset()
state.content.WriteString(fullContent)
state.mu.Unlock()
return nil
}
state.content.Reset()
state.content.WriteString(fullContent)
chatID := state.chatID
msgID := state.msgID
state.lastEdit = time.Now()
state.mu.Unlock()
if err := a.editMessage(ctx, chatID, msgID, fullContent, ""); err != nil {
logger.Warnf(ctx, "[Telegram] Failed to update stream content: %v", err)
}
return nil
}
func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessage, streamID string, finalContent string) error {
streamsMu.Lock()
state, ok := streams[streamID]
streamsMu.Unlock()
if !ok {
return fmt.Errorf("unknown stream ID: %s", streamID)
}
state.mu.Lock()
state.content.Reset()
state.content.WriteString(finalContent)
chatID := state.chatID
msgID := state.msgID
state.mu.Unlock()
if err := a.editMessage(ctx, chatID, msgID, finalContent, "Markdown"); err != nil {
logger.Warnf(ctx, "[Telegram] Markdown finalize failed, retrying plain: %v", err)
if retryErr := a.editMessage(ctx, chatID, msgID, finalContent, ""); retryErr != nil {
logger.Warnf(ctx, "[Telegram] Failed to finalize stream: %v", retryErr)
}
}
return nil
}
func (a *Adapter) SendStreamChunk(ctx context.Context, incoming *im.IncomingMessage, streamID string, content string) error {
return a.UpdateStreamContent(ctx, incoming, streamID, content)
}
func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
streamsMu.Lock()
_, ok := streams[streamID]
delete(streams, streamID)
streamsMu.Unlock()
if !ok {
return nil
}
logger.Infof(ctx, "[Telegram] Streaming ended: stream_id=%s", streamID)
return nil
}
// ── FileDownloader implementation ──
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
if msg.FileKey == "" {
return nil, "", fmt.Errorf("file_key is required")
}
// Get file path via getFile API
var fileInfo struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
}
if err := a.callAPIWithResult(ctx, "getFile", map[string]string{"file_id": msg.FileKey}, &fileInfo); err != nil {
return nil, "", fmt.Errorf("get file info: %w", err)
}
// Download the file
downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", a.botToken, fileInfo.FilePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download file: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("download failed with status %d", resp.StatusCode)
}
return resp.Body, msg.FileName, nil
}
+108
View File
@@ -0,0 +1,108 @@
package telegram
import (
"testing"
"github.com/Tencent/WeKnora/internal/im"
)
func TestParseTelegramMessage_ForumTopicThread(t *testing.T) {
msg := &telegramMsg{
MessageID: 100,
MessageThreadID: 42, // Forum topic ID
From: &telegramUser{ID: 1001, FirstName: "Alice"},
Chat: telegramChat{ID: -9999, Type: "supergroup"},
Text: "hello",
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
if incoming.ThreadID != "42" {
t.Errorf("ThreadID = %q, want %q", incoming.ThreadID, "42")
}
}
func TestParseTelegramMessage_NonForumGroup(t *testing.T) {
msg := &telegramMsg{
MessageID: 200,
MessageThreadID: 0, // not a Forum group
From: &telegramUser{ID: 1002, FirstName: "Bob"},
Chat: telegramChat{ID: -8888, Type: "group"},
Text: "hello",
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
// Non-Forum groups: ThreadID should be empty
if incoming.ThreadID != "" {
t.Errorf("ThreadID = %q, want empty for non-Forum group", incoming.ThreadID)
}
}
func TestParseTelegramMessage_DirectMessage(t *testing.T) {
msg := &telegramMsg{
MessageID: 300,
From: &telegramUser{ID: 1003, FirstName: "Carol"},
Chat: telegramChat{ID: 1003, Type: "private"},
Text: "hi bot",
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
if incoming.ThreadID != "" {
t.Errorf("ThreadID = %q, want empty for DM", incoming.ThreadID)
}
if incoming.ChatType != im.ChatTypeDirect {
t.Errorf("ChatType = %q, want %q", incoming.ChatType, im.ChatTypeDirect)
}
}
func TestParseTelegramMessage_NilMessage(t *testing.T) {
incoming := parseTelegramMessage(nil)
if incoming != nil {
t.Error("expected nil for nil message")
}
}
func TestParseTelegramMessage_Document(t *testing.T) {
msg := &telegramMsg{
MessageID: 400,
MessageThreadID: 7, // in a Forum topic
From: &telegramUser{ID: 1004, FirstName: "Dave"},
Chat: telegramChat{ID: -7777, Type: "supergroup"},
Document: &telegramDoc{
FileID: "doc-123",
FileName: "report.pdf",
FileSize: 2048,
},
}
incoming := parseTelegramMessage(msg)
if incoming == nil {
t.Fatal("expected non-nil message")
}
if incoming.ThreadID != "7" {
t.Errorf("ThreadID = %q, want %q", incoming.ThreadID, "7")
}
if incoming.MessageType != im.MessageTypeFile {
t.Errorf("MessageType = %q, want %q", incoming.MessageType, im.MessageTypeFile)
}
}
func TestParseUpdate_NilMessage(t *testing.T) {
update := &telegramUpdate{UpdateID: 1, Message: nil}
incoming := parseUpdate(update)
if incoming != nil {
t.Error("expected nil for nil update.Message")
}
}
+47
View File
@@ -0,0 +1,47 @@
package telegram
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
)
// NewFactory returns an im.AdapterFactory for Telegram bot channels.
// Supports "webhook" and "websocket" (long polling, default).
func NewFactory() im.AdapterFactory {
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
creds, err := im.ParseCredentials(channel.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("parse telegram credentials: %w", err)
}
botToken := im.GetString(creds, "bot_token")
mode := im.ResolveMode(channel, "websocket")
switch mode {
case "webhook":
secretToken := im.GetString(creds, "secret_token")
adapter := NewWebhookAdapter(botToken, secretToken)
return adapter, nil, nil
case "websocket":
client := NewLongConnClient(botToken, msgHandler)
wsCtx, wsCancel := context.WithCancel(context.Background())
go func() {
if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil {
logger.Errorf(context.Background(), "[IM] Telegram long polling stopped for channel %s: %v", channel.ID, err)
}
}()
adapter := NewAdapter(client, botToken)
return adapter, wsCancel, nil
default:
return nil, nil, fmt.Errorf("unsupported telegram mode: %s", mode)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// MessageHandler is called when an IM message is received via long polling.
type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
// LongConnClient manages a Telegram long-polling connection.
type LongConnClient struct {
botToken string
handler MessageHandler
offset int
httpClient *http.Client
}
// NewLongConnClient creates a Telegram long-polling client.
func NewLongConnClient(botToken string, handler MessageHandler) *LongConnClient {
return &LongConnClient{
botToken: botToken,
handler: handler,
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 35 * time.Second,
MaxRedirects: 5,
}),
}
}
// Start begins the long-polling loop. It blocks until ctx is cancelled.
func (c *LongConnClient) Start(ctx context.Context) error {
logger.Infof(ctx, "[IM] Telegram long polling connecting...")
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
updates, err := c.getUpdates(ctx)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
logger.Errorf(ctx, "[Telegram] getUpdates error: %v", err)
// Back off on error
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
continue
}
for _, update := range updates {
if update.UpdateID >= c.offset {
c.offset = update.UpdateID + 1
}
msg := parseUpdate(&update)
if msg == nil {
continue
}
if err := c.handler(ctx, msg); err != nil {
logger.Errorf(ctx, "[Telegram] Handle message error: %v", err)
}
}
}
}
func (c *LongConnClient) getUpdates(ctx context.Context) ([]telegramUpdate, error) {
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates", c.botToken)
body := map[string]interface{}{
"offset": c.offset,
"timeout": 30,
"allowed_updates": []string{"message"},
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var apiResp struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result []telegramUpdate `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if !apiResp.OK {
return nil, fmt.Errorf("getUpdates failed: %s", apiResp.Description)
}
return apiResp.Result, nil
}