Files
multica/server/internal/handler/telegram.go
T
67c6aa05c4 MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration

Co-authored-by: multica-agent <[email protected]>

* chore(telegram): rebase onto main and renumber migrations to 366-367

349/350 collided with main's agent_task_queue_chat_terminal_resume_index
and agent_task_queue_chat_retired_session_index. Renumber the Telegram
origin pair to the next free prefixes, update the cross-references inside
the SQL comments, and point telegram_origin_migration_test.go at the new
filenames. Contributor-facing behavior is unchanged.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-08-19 23:04:26 +08:00

266 lines
10 KiB
Go

package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/integrations/telegram"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// TelegramInstallationResponse is the wire shape for a Telegram installation
// row. The encrypted bot token in config is INTENTIONALLY absent — it is
// server-internal. WS lease columns are runtime state, not API surface.
type TelegramInstallationResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
AgentID string `json:"agent_id"`
BotID string `json:"bot_id"`
BotUsername string `json:"bot_username"`
InstallerUserID string `json:"installer_user_id"`
Status string `json:"status"`
InstalledAt string `json:"installed_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func telegramInstallationToResponse(row db.ChannelInstallation) TelegramInstallationResponse {
info := telegram.DecodePublicConfig(row.Config)
return TelegramInstallationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
AgentID: uuidToString(row.AgentID),
BotID: info.BotID,
BotUsername: info.BotUsername,
InstallerUserID: uuidToString(row.InstallerUserID),
Status: row.Status,
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
}
}
// ListTelegramInstallations (GET /api/workspaces/{id}/telegram/installations)
// is member-visible so the Integrations tab renders for non-admins. Response
// flags mirror Slack: configured = at-rest key set; install_supported is true
// whenever configured (paste-a-token needs no hosted credential).
func (h *Handler) ListTelegramInstallations(w http.ResponseWriter, r *http.Request) {
if h.TelegramInstall == nil {
writeJSON(w, http.StatusOK, map[string]any{
"installations": []TelegramInstallationResponse{},
"configured": false,
"install_supported": false,
})
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
rows, err := h.TelegramInstall.ListByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list telegram installations")
return
}
out := make([]TelegramInstallationResponse, 0, len(rows))
for _, row := range rows {
out = append(out, telegramInstallationToResponse(row))
}
writeJSON(w, http.StatusOK, map[string]any{
"installations": out,
"configured": true,
"install_supported": true,
})
}
// RegisterTelegramRequest is the body for a bot install: the token the user
// pasted from @BotFather.
type RegisterTelegramRequest struct {
BotToken string `json:"bot_token"`
}
// RegisterTelegramBot (POST /api/workspaces/{id}/telegram/install?agent_id=…)
// installs a user-supplied Telegram bot for an agent. Admin-only at the
// router. Mirrors RegisterSlackBYO.
func (h *Handler) RegisterTelegramBot(w http.ResponseWriter, r *http.Request) {
if h.TelegramInstall == nil {
writeError(w, http.StatusServiceUnavailable, "telegram integration not enabled")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentIDStr == "" {
writeError(w, http.StatusBadRequest, "agent_id is required")
return
}
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
if !ok {
return
}
// Ownership pre-check at the boundary so a wrong agent_id is a clear 404.
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "agent not found in this workspace")
return
}
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
var body RegisterTelegramRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
row, err := h.TelegramInstall.Register(r.Context(), telegram.RegisterParams{
WorkspaceID: wsUUID,
AgentID: agentUUID,
InitiatorID: initiatorUUID,
BotToken: body.BotToken,
})
if err != nil {
switch {
case errors.Is(err, telegram.ErrInvalidBotToken):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, telegram.ErrCredentialsRejected):
writeError(w, http.StatusBadRequest, "Telegram rejected this bot token — generate a current token in @BotFather and try again")
case errors.Is(err, telegram.ErrCredentialsUnverifiable):
writeError(w, http.StatusServiceUnavailable, "could not reach Telegram to verify this bot — check the server network or proxy and try again; the token was not saved")
case errors.Is(err, telegram.ErrBotOwnedBySameWorkspace):
writeError(w, http.StatusConflict, "this Telegram bot is already connected to another agent in this workspace — disconnect it there first, then connect it here")
case errors.Is(err, telegram.ErrBotOwnedByArchivedAgent):
writeError(w, http.StatusConflict, "this Telegram bot is connected to an archived agent in this workspace — restore that agent, or disconnect its bot, before connecting it here")
case errors.Is(err, telegram.ErrBotOwnedByAnotherWorkspace):
writeError(w, http.StatusConflict, "this Telegram bot is already connected to a different Multica workspace — disconnect it there before connecting it here")
case errors.Is(err, telegram.ErrWebhookConfigured):
writeError(w, http.StatusBadRequest, "this Telegram bot has a webhook configured — remove the webhook before connecting it with long polling")
default:
writeError(w, http.StatusInternalServerError, "could not save this Telegram bot — something went wrong on the server; the token was not saved")
}
return
}
// Broadcast so every open client invalidates its installations query and
// shows the new bot — matching the Slack install semantics.
h.publish(protocol.EventTelegramInstallationCreated, uuidToString(row.WorkspaceID), "user", userID, map[string]any{
"id": uuidToString(row.ID),
})
writeJSON(w, http.StatusOK, telegramInstallationToResponse(row))
}
// RevokeTelegramInstallation (DELETE /api/workspaces/{id}/telegram/installations/{installationId})
// flips status to 'revoked'. Admin-only at the router. The row is preserved
// for audit and chat history stays in Multica; a re-install (re-pasting the
// bot's token) flips status back to 'active'.
func (h *Handler) RevokeTelegramInstallation(w http.ResponseWriter, r *http.Request) {
if h.TelegramInstall == nil {
writeError(w, http.StatusServiceUnavailable, "telegram integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
if !ok {
return
}
// Workspace-scoped lookup so one workspace cannot revoke another's
// installation by guessing the UUID.
if _, err := h.TelegramInstall.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
if errors.Is(err, telegram.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "telegram installation not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load installation")
return
}
if err := h.TelegramInstall.Revoke(r.Context(), instUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
return
}
h.publish(protocol.EventTelegramInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
"id": uuidToString(instUUID),
})
w.WriteHeader(http.StatusNoContent)
}
// RedeemTelegramBindingTokenRequest carries the raw token from the bot's
// "link your account" prompt.
type RedeemTelegramBindingTokenRequest struct {
Token string `json:"token"`
}
// RedeemTelegramBindingTokenResponse echoes the bound identifiers so the
// frontend can confirm without a second fetch.
type RedeemTelegramBindingTokenResponse struct {
WorkspaceID string `json:"workspace_id"`
InstallationID string `json:"installation_id"`
TelegramUserID string `json:"telegram_user_id"`
}
// RedeemTelegramBindingToken (POST /api/telegram/binding/redeem) binds the
// Telegram user id carried by the token to the logged-in Multica user. The
// redeemer's identity comes from the session, not the token. Status codes
// mirror the Slack redeem handler.
func (h *Handler) RedeemTelegramBindingToken(w http.ResponseWriter, r *http.Request) {
if h.TelegramBindingTokens == nil {
writeError(w, http.StatusServiceUnavailable, "telegram integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req RedeemTelegramBindingTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "token is required")
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
redeemed, err := h.TelegramBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
if err != nil {
switch {
case errors.Is(err, telegram.ErrBindingTokenInvalid):
writeError(w, http.StatusGone, "binding token invalid or expired")
case errors.Is(err, telegram.ErrBindingAlreadyAssigned):
writeError(w, http.StatusConflict, "this Telegram account is already bound to a different Multica user")
case errors.Is(err, telegram.ErrBindingNotWorkspaceMember):
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
default:
writeError(w, http.StatusInternalServerError, "failed to redeem token")
}
return
}
writeJSON(w, http.StatusOK, RedeemTelegramBindingTokenResponse{
WorkspaceID: uuidToString(redeemed.WorkspaceID),
InstallationID: uuidToString(redeemed.InstallationID),
TelegramUserID: redeemed.TelegramUserID,
})
}