refactor: python -> go
All checks were successful
ci/woodpecker/tag/release Pipeline was successful

This commit is contained in:
Felipe M 2025-04-20 13:54:22 +02:00
parent 9c78ea2d48
commit 7c684af8c3
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
79 changed files with 3594 additions and 3257 deletions

32
internal/platform/init.go Normal file
View file

@ -0,0 +1,32 @@
package platform
import (
"fmt"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/platform/slack"
"git.nakama.town/fmartingr/butterrobot/internal/platform/telegram"
)
// InitializePlatforms initializes all available platforms
func InitializePlatforms(cfg *config.Config) error {
// Initialize Slack platform
if cfg.SlackConfig.Token != "" && cfg.SlackConfig.BotOAuthAccessToken != "" {
slackPlatform := slack.New(&cfg.SlackConfig)
if err := slackPlatform.Init(cfg); err == nil {
Register("slack", slackPlatform)
}
}
// Initialize Telegram platform
if cfg.TelegramConfig.Token != "" {
telegramPlatform := telegram.New(&cfg.TelegramConfig)
if err := telegramPlatform.Init(cfg); err == nil {
Register("telegram", telegramPlatform)
}
} else {
return fmt.Errorf("telegram token is required")
}
return nil
}

View file

@ -0,0 +1,49 @@
package platform
import (
"sync"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
var (
// platforms holds all registered chat platforms
platforms = make(map[string]model.Platform)
// platformsMu protects the platforms map
platformsMu sync.RWMutex
)
// Register registers a platform with the given ID
func Register(id string, platform model.Platform) {
platformsMu.Lock()
defer platformsMu.Unlock()
platforms[id] = platform
}
// Get returns a platform by ID
func Get(id string) (model.Platform, error) {
platformsMu.RLock()
defer platformsMu.RUnlock()
platform, exists := platforms[id]
if !exists {
return nil, model.ErrPlatformNotFound
}
return platform, nil
}
// GetAvailablePlatforms returns all registered platforms
func GetAvailablePlatforms() map[string]model.Platform {
platformsMu.RLock()
defer platformsMu.RUnlock()
// Create a copy to avoid race conditions
result := make(map[string]model.Platform, len(platforms))
for id, platform := range platforms {
result[id] = platform
}
return result
}

View file

@ -0,0 +1,212 @@
package slack
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// SlackPlatform implements the Platform interface for Slack
type SlackPlatform struct {
config *config.SlackConfig
}
// New creates a new SlackPlatform instance
func New(cfg *config.SlackConfig) *SlackPlatform {
return &SlackPlatform{
config: cfg,
}
}
// Init initializes the Slack platform
func (s *SlackPlatform) Init(_ *config.Config) error {
// Validate config
if s.config.Token == "" || s.config.BotOAuthAccessToken == "" {
return model.ErrPlatformInit
}
return nil
}
// ParseIncomingMessage parses an incoming Slack message
func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
// Read request body
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, err
}
defer r.Body.Close()
// Parse JSON
var requestData map[string]interface{}
if err := json.Unmarshal(body, &requestData); err != nil {
return nil, err
}
// Verify Slack request
// This is a simplified version, production should include signature verification
urlVerify, ok := requestData["type"]
if ok && urlVerify == "url_verification" {
return nil, errors.New("url verification") // Handle separately
}
// Process event
event, ok := requestData["event"].(map[string]interface{})
if !ok {
return nil, errors.New("invalid event")
}
// Create message
msg := &model.Message{
Raw: requestData,
}
// Get text
if text, ok := event["text"].(string); ok {
msg.Text = text
}
// Get channel
if channel, ok := event["channel"].(string); ok {
msg.Chat = channel
// Create Channel object
channelRaw, err := s.ParseChannelFromMessage(body)
if err != nil {
return nil, err
}
msg.Channel = &model.Channel{
Platform: "slack",
PlatformChannelID: channel,
ChannelRaw: channelRaw,
}
}
// Check if from bot
if botID, ok := event["bot_id"].(string); ok && botID != "" {
msg.FromBot = true
}
// Get user
if user, ok := event["user"].(string); ok {
msg.Author = user
}
// Get timestamp
if ts, ok := event["ts"].(string); ok {
// Convert Unix timestamp
parts := strings.Split(ts, ".")
if len(parts) > 0 {
if sec, err := parseInt64(parts[0]); err == nil {
msg.Date = time.Unix(sec, 0)
msg.ID = ts
}
}
}
return msg, nil
}
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
func (s *SlackPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string {
// Extract name from channel raw data
if name, ok := channelRaw["name"].(string); ok {
return name
}
// Fallback to ID if available
if id, ok := channelRaw["id"].(string); ok {
return id
}
return "unknown"
}
// ParseChannelFromMessage extracts channel data from a message
func (s *SlackPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) {
// Parse JSON
var requestData map[string]interface{}
if err := json.Unmarshal(body, &requestData); err != nil {
return nil, err
}
// Extract channel info from event
event, ok := requestData["event"].(map[string]interface{})
if !ok {
return nil, errors.New("invalid event data")
}
channelID, ok := event["channel"].(string)
if !ok {
return nil, errors.New("channel ID not found")
}
// In a real implementation, you might want to fetch more details about the channel
// using the Slack API, but for simplicity we'll just return the ID
channelRaw := map[string]interface{}{
"id": channelID,
}
return channelRaw, nil
}
// SendMessage sends a message to Slack
func (s *SlackPlatform) SendMessage(msg *model.Message) error {
if s.config.BotOAuthAccessToken == "" {
return errors.New("bot token not configured")
}
// Prepare payload
payload := map[string]interface{}{
"channel": msg.Chat,
"text": msg.Text,
}
// Add thread_ts if it's a reply
if msg.ReplyTo != "" {
payload["thread_ts"] = msg.ReplyTo
}
// Convert payload to JSON
data, err := json.Marshal(payload)
if err != nil {
return err
}
// Send HTTP request
req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", strings.NewReader(string(data)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("slack API error: %d", resp.StatusCode)
}
return nil
}
// Helper function to parse int64
func parseInt64(s string) (int64, error) {
var n int64
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}

View file

@ -0,0 +1,262 @@
package telegram
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// TelegramPlatform implements the Platform interface for Telegram
type TelegramPlatform struct {
config *config.TelegramConfig
apiURL string
log *slog.Logger
}
// New creates a new TelegramPlatform instance
func New(cfg *config.TelegramConfig) *TelegramPlatform {
return &TelegramPlatform{
config: cfg,
apiURL: "https://api.telegram.org/bot" + cfg.Token,
log: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})).With(slog.String("platform", "telegram")),
}
}
// Init initializes the Telegram platform
func (t *TelegramPlatform) Init(cfg *config.Config) error {
if t.config.Token == "" {
t.log.Error("Missing Telegram token")
return model.ErrPlatformInit
}
// Set webhook URL based on hostname
webhookURL := fmt.Sprintf("https://%s/telegram/incoming/%s", cfg.Hostname, t.config.Token)
t.log.Info("Setting Telegram webhook", "url", webhookURL)
// Create webhook setup request
url := fmt.Sprintf("%s/setWebhook", t.apiURL)
payload := map[string]interface{}{
"url": webhookURL,
"max_connections": 40,
"allowed_updates": []string{"message"},
}
data, err := json.Marshal(payload)
if err != nil {
t.log.Error("Failed to marshal webhook payload", "error", err)
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
resp, err := http.Post(url, "application/json", bytes.NewBuffer(data))
if err != nil {
t.log.Error("Failed to set webhook", "error", err)
return fmt.Errorf("failed to set webhook: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errMsg := string(bodyBytes)
t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg)
return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg)
}
t.log.Info("Telegram webhook successfully set")
return nil
}
// ParseIncomingMessage parses an incoming Telegram message
func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
t.log.Debug("Parsing incoming Telegram message")
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
t.log.Error("Failed to read request body", "error", err)
return nil, err
}
defer r.Body.Close()
// Parse JSON
var update struct {
Message struct {
MessageID int `json:"message_id"`
From struct {
ID int `json:"id"`
IsBot bool `json:"is_bot"`
Username string `json:"username"`
FirstName string `json:"first_name"`
} `json:"from"`
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
} `json:"chat"`
Date int `json:"date"`
Text string `json:"text"`
} `json:"message"`
}
if err := json.Unmarshal(body, &update); err != nil {
t.log.Error("Failed to unmarshal update", "error", err)
return nil, err
}
// Convert to raw map for storage
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
t.log.Error("Failed to unmarshal raw data", "error", err)
return nil, err
}
// Create message
msg := &model.Message{
Text: update.Message.Text,
Chat: strconv.FormatInt(update.Message.Chat.ID, 10),
Author: update.Message.From.Username,
FromBot: update.Message.From.IsBot,
Date: time.Unix(int64(update.Message.Date), 0),
ID: strconv.Itoa(update.Message.MessageID),
Raw: raw,
}
t.log.Debug("Parsed message",
"id", msg.ID,
"chat", msg.Chat,
"author", msg.Author,
"from_bot", msg.FromBot,
"text_length", len(msg.Text))
// Create Channel object
channelRaw, err := t.ParseChannelFromMessage(body)
if err != nil {
t.log.Error("Failed to parse channel data", "error", err)
return nil, err
}
msg.Channel = &model.Channel{
Platform: "telegram",
PlatformChannelID: msg.Chat,
ChannelRaw: channelRaw,
}
return msg, nil
}
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
func (t *TelegramPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string {
// Try to get the title first (for groups)
if chatInfo, ok := channelRaw["chat"].(map[string]interface{}); ok {
if title, ok := chatInfo["title"].(string); ok && title != "" {
return title
}
// For private chats, use username
if username, ok := chatInfo["username"].(string); ok && username != "" {
return username
}
// Fallback to first_name if available
if firstName, ok := chatInfo["first_name"].(string); ok && firstName != "" {
return firstName
}
// Last resort: use the ID
if id, ok := chatInfo["id"].(float64); ok {
return strconv.FormatInt(int64(id), 10)
}
}
return "unknown"
}
// ParseChannelFromMessage extracts channel data from a message
func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) {
// Parse JSON to extract chat info
var update struct {
Message struct {
Chat map[string]any `json:"chat"`
} `json:"message"`
}
if err := json.Unmarshal(body, &update); err != nil {
return nil, err
}
if update.Message.Chat == nil {
return nil, errors.New("chat information not found")
}
return map[string]any{
"chat": update.Message.Chat,
}, nil
}
// SendMessage sends a message to Telegram
func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
// Convert chat ID to int64
chatID, err := strconv.ParseInt(msg.Chat, 10, 64)
if err != nil {
t.log.Error("Failed to parse chat ID", "chat", msg.Chat, "error", err)
return err
}
// Prepare payload
payload := map[string]interface{}{
"chat_id": chatID,
"text": msg.Text,
}
// Add reply if needed
if msg.ReplyTo != "" {
replyToID, err := strconv.Atoi(msg.ReplyTo)
if err == nil {
payload["reply_to_message_id"] = replyToID
} else {
t.log.Warn("Failed to parse reply_to ID", "reply_to", msg.ReplyTo, "error", err)
}
}
t.log.Debug("Sending message to Telegram", "chat_id", chatID, "length", len(msg.Text))
// Convert payload to JSON
data, err := json.Marshal(payload)
if err != nil {
t.log.Error("Failed to marshal message payload", "error", err)
return err
}
// Send HTTP request
resp, err := http.Post(
t.apiURL+"/sendMessage",
"application/json",
bytes.NewBuffer(data),
)
if err != nil {
t.log.Error("Failed to send message", "error", err)
return err
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errMsg := string(bodyBytes)
t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg)
return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg)
}
t.log.Debug("Message sent successfully")
return nil
}