mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
266 lines
10 KiB
Go
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,
|
|
})
|
|
}
|