forked from molecule-ai/molecule-core
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
423 lines
13 KiB
Go
423 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
)
|
|
|
|
// ChannelHandler manages workspace social channel integrations.
|
|
type ChannelHandler struct {
|
|
manager *channels.Manager
|
|
}
|
|
|
|
// NewChannelHandler creates a channel handler with the given manager.
|
|
func NewChannelHandler(manager *channels.Manager) *ChannelHandler {
|
|
return &ChannelHandler{manager: manager}
|
|
}
|
|
|
|
// ListAdapters returns all available channel adapter types.
|
|
func (h *ChannelHandler) ListAdapters(c *gin.Context) {
|
|
c.JSON(http.StatusOK, channels.ListAdapters())
|
|
}
|
|
|
|
// List returns all channels for a workspace.
|
|
func (h *ChannelHandler) List(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
rows, err := db.DB.QueryContext(ctx, `
|
|
SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users,
|
|
last_message_at, message_count, created_at, updated_at
|
|
FROM workspace_channels WHERE workspace_id = $1
|
|
ORDER BY created_at
|
|
`, workspaceID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var id, wsID, chType string
|
|
var configJSON, allowedJSON []byte
|
|
var enabled bool
|
|
var lastMsg sql.NullTime
|
|
var msgCount int
|
|
var createdAt, updatedAt sql.NullTime
|
|
|
|
if err := rows.Scan(&id, &wsID, &chType, &configJSON, &enabled, &allowedJSON, &lastMsg, &msgCount, &createdAt, &updatedAt); err != nil {
|
|
continue
|
|
}
|
|
|
|
var config map[string]interface{}
|
|
json.Unmarshal(configJSON, &config)
|
|
// Mask bot_token in list response
|
|
if _, ok := config["bot_token"]; ok {
|
|
token, _ := config["bot_token"].(string)
|
|
if len(token) > 8 {
|
|
config["bot_token"] = token[:4] + "..." + token[len(token)-4:]
|
|
} else {
|
|
config["bot_token"] = "***"
|
|
}
|
|
}
|
|
|
|
var allowed []string
|
|
json.Unmarshal(allowedJSON, &allowed)
|
|
|
|
entry := map[string]interface{}{
|
|
"id": id,
|
|
"workspace_id": wsID,
|
|
"channel_type": chType,
|
|
"config": config,
|
|
"enabled": enabled,
|
|
"allowed_users": allowed,
|
|
"message_count": msgCount,
|
|
"created_at": createdAt.Time,
|
|
"updated_at": updatedAt.Time,
|
|
}
|
|
if lastMsg.Valid {
|
|
entry["last_message_at"] = lastMsg.Time
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// Create adds a new channel to a workspace.
|
|
func (h *ChannelHandler) Create(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
var body struct {
|
|
ChannelType string `json:"channel_type"`
|
|
Config map[string]interface{} `json:"config"`
|
|
AllowedUsers []string `json:"allowed_users"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
|
|
if body.ChannelType == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_type is required"})
|
|
return
|
|
}
|
|
|
|
adapter, ok := channels.GetAdapter(body.ChannelType)
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported channel_type: " + body.ChannelType})
|
|
return
|
|
}
|
|
|
|
if err := adapter.ValidateConfig(body.Config); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
configJSON, _ := json.Marshal(body.Config)
|
|
allowedJSON, _ := json.Marshal(body.AllowedUsers)
|
|
enabled := true
|
|
if body.Enabled != nil {
|
|
enabled = *body.Enabled
|
|
}
|
|
|
|
var id string
|
|
err := db.DB.QueryRowContext(ctx, `
|
|
INSERT INTO workspace_channels (workspace_id, channel_type, channel_config, enabled, allowed_users)
|
|
VALUES ($1, $2, $3::jsonb, $4, $5::jsonb)
|
|
RETURNING id
|
|
`, workspaceID, body.ChannelType, string(configJSON), enabled, string(allowedJSON)).Scan(&id)
|
|
if err != nil {
|
|
log.Printf("Channels: create failed for workspace %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
|
return
|
|
}
|
|
|
|
// Hot reload
|
|
h.manager.Reload(ctx)
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": id,
|
|
"channel_type": body.ChannelType,
|
|
"enabled": enabled,
|
|
})
|
|
}
|
|
|
|
// Update modifies a channel's config, allowlist, or enabled state.
|
|
func (h *ChannelHandler) Update(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
channelID := c.Param("channelId")
|
|
ctx := c.Request.Context()
|
|
|
|
var body struct {
|
|
Config map[string]interface{} `json:"config"`
|
|
AllowedUsers []string `json:"allowed_users"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
|
|
// COALESCE-based update
|
|
var configArg, allowedArg interface{}
|
|
if body.Config != nil {
|
|
j, _ := json.Marshal(body.Config)
|
|
configArg = string(j)
|
|
}
|
|
if body.AllowedUsers != nil {
|
|
j, _ := json.Marshal(body.AllowedUsers)
|
|
allowedArg = string(j)
|
|
}
|
|
|
|
result, err := db.DB.ExecContext(ctx, `
|
|
UPDATE workspace_channels
|
|
SET channel_config = COALESCE($3::jsonb, channel_config),
|
|
allowed_users = COALESCE($4::jsonb, allowed_users),
|
|
enabled = COALESCE($5, enabled),
|
|
updated_at = now()
|
|
WHERE id = $1 AND workspace_id = $2
|
|
`, channelID, workspaceID, configArg, allowedArg, body.Enabled)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
|
return
|
|
}
|
|
|
|
if n, _ := result.RowsAffected(); n == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
|
return
|
|
}
|
|
|
|
// Hot reload
|
|
h.manager.Reload(ctx)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
|
}
|
|
|
|
// Delete removes a channel from a workspace.
|
|
func (h *ChannelHandler) Delete(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
channelID := c.Param("channelId")
|
|
ctx := c.Request.Context()
|
|
|
|
result, err := db.DB.ExecContext(ctx, `
|
|
DELETE FROM workspace_channels WHERE id = $1 AND workspace_id = $2
|
|
`, channelID, workspaceID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
|
return
|
|
}
|
|
|
|
if n, _ := result.RowsAffected(); n == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
|
return
|
|
}
|
|
|
|
// Hot reload
|
|
h.manager.Reload(ctx)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
|
}
|
|
|
|
// Send sends an outbound message from a workspace to its social channel.
|
|
func (h *ChannelHandler) Send(c *gin.Context) {
|
|
channelID := c.Param("channelId")
|
|
ctx := c.Request.Context()
|
|
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
|
|
return
|
|
}
|
|
|
|
if err := h.manager.SendOutbound(ctx, channelID, body.Text); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
|
}
|
|
|
|
// Test sends a test message to verify the channel is working.
|
|
func (h *ChannelHandler) Test(c *gin.Context) {
|
|
channelID := c.Param("channelId")
|
|
ctx := c.Request.Context()
|
|
|
|
if err := h.manager.SendOutbound(ctx, channelID, "🔔 Molecule AI channel test — connection successful!"); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "test message sent"})
|
|
}
|
|
|
|
// matchesChatID returns true if chatID is in the channel's comma-separated chat_id list.
|
|
// Exact match — avoids the substring-match bug of SQL LIKE.
|
|
func matchesChatID(config map[string]interface{}, chatID string) bool {
|
|
raw, _ := config["chat_id"].(string)
|
|
if raw == "" {
|
|
return false
|
|
}
|
|
for _, s := range strings.Split(raw, ",") {
|
|
if strings.TrimSpace(s) == chatID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Discover auto-detects chats/groups a bot has been added to by calling the platform API.
|
|
// User flow: enter bot token → add bot to groups → send a message → click Detect → select groups.
|
|
func (h *ChannelHandler) Discover(c *gin.Context) {
|
|
var body struct {
|
|
ChannelType string `json:"channel_type"`
|
|
BotToken string `json:"bot_token"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.BotToken == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
|
|
return
|
|
}
|
|
|
|
adapter, ok := channels.GetAdapter(body.ChannelType)
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported channel_type"})
|
|
return
|
|
}
|
|
|
|
// Only Telegram supports discovery currently
|
|
tg, ok := adapter.(*channels.TelegramAdapter)
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "discovery not supported for " + body.ChannelType})
|
|
return
|
|
}
|
|
|
|
// Pause any active poller using this bot token to avoid Telegram's
|
|
// "only one getUpdates at a time" 409 Conflict.
|
|
resumeFn := h.manager.PausePollersForToken(body.BotToken)
|
|
defer resumeFn()
|
|
|
|
result, err := tg.DiscoverChats(c.Request.Context(), body.BotToken)
|
|
if err != nil {
|
|
// Map known errors to user-friendly messages
|
|
msg := err.Error()
|
|
userMsg := "Failed to connect to Telegram. Check your bot token and try again."
|
|
if strings.Contains(msg, "invalid bot token") || strings.Contains(msg, "Unauthorized") || strings.Contains(msg, "Not Found") {
|
|
userMsg = "Invalid bot token. Check the token from @BotFather and try again."
|
|
} else if strings.Contains(msg, "Conflict") || strings.Contains(msg, "terminated by other") {
|
|
userMsg = "This bot is already connected to another channel. Disconnect the existing channel first, or wait 30 seconds and retry."
|
|
} else if strings.Contains(msg, "no route to host") || strings.Contains(msg, "i/o timeout") {
|
|
userMsg = "Cannot reach Telegram API. Check your network connection and try again."
|
|
}
|
|
log.Printf("Channels: discover error: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": userMsg})
|
|
return
|
|
}
|
|
|
|
hint := "For groups: add bot and send a message. For DMs: send /start to the bot. Then retry."
|
|
var warning string
|
|
if !result.CanReadAllGroupMessages {
|
|
warning = "⚠️ Group privacy mode is ON for this bot — it only sees commands and @mentions in groups. " +
|
|
"To let it see all group messages: open @BotFather → /mybots → " + result.BotUsername +
|
|
" → Bot Settings → Group Privacy → Turn off, then re-add the bot to the group."
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"bot_username": result.BotUsername,
|
|
"chats": result.Chats,
|
|
"can_read_all_group_messages": result.CanReadAllGroupMessages,
|
|
"privacy_warning": warning,
|
|
"hint": hint,
|
|
})
|
|
}
|
|
|
|
// Webhook handles incoming webhooks from any social platform.
|
|
func (h *ChannelHandler) Webhook(c *gin.Context) {
|
|
channelType := c.Param("type")
|
|
ctx := c.Request.Context()
|
|
|
|
adapter, ok := channels.GetAdapter(channelType)
|
|
if !ok {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown channel type"})
|
|
return
|
|
}
|
|
|
|
// For webhooks, we need to find the channel by type and match by chat_id in the message
|
|
// Parse the webhook first to get the chat_id
|
|
msg, err := adapter.ParseWebhook(c, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "parse error: " + err.Error()})
|
|
return
|
|
}
|
|
if msg == nil {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ignored"}) // Non-message update
|
|
return
|
|
}
|
|
|
|
// Look up channels by type and find one whose chat_id list contains msg.ChatID.
|
|
// We can't use SQL LIKE — that matches substrings (chat_id "123" would match "1234").
|
|
// Fetch all enabled channels of this type, then exact-match in code.
|
|
rows, err := db.DB.QueryContext(ctx, `
|
|
SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users
|
|
FROM workspace_channels
|
|
WHERE channel_type = $1 AND enabled = true
|
|
`, channelType)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "channel lookup failed"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ch channels.ChannelRow
|
|
found := false
|
|
for rows.Next() {
|
|
var row channels.ChannelRow
|
|
var configJSON, allowedJSON []byte
|
|
if err := rows.Scan(&row.ID, &row.WorkspaceID, &row.ChannelType, &configJSON, &row.Enabled, &allowedJSON); err != nil {
|
|
continue
|
|
}
|
|
json.Unmarshal(configJSON, &row.Config)
|
|
json.Unmarshal(allowedJSON, &row.AllowedUsers)
|
|
|
|
// Verify webhook secret_token if the channel has one configured
|
|
if expectedSecret, _ := row.Config["webhook_secret"].(string); expectedSecret != "" {
|
|
receivedSecret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
|
|
if receivedSecret != expectedSecret {
|
|
continue // Wrong secret — try other channels (could be different bot)
|
|
}
|
|
}
|
|
|
|
// Exact match against the comma-separated chat_id list
|
|
if matchesChatID(row.Config, msg.ChatID) {
|
|
ch = row
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusOK, gin.H{"status": "no_channel"})
|
|
return
|
|
}
|
|
|
|
// Process asynchronously — don't block the webhook response
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
_ = h.manager.HandleInbound(bgCtx, ch, msg)
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
|
}
|