Files
Memoh/internal/channel/adapters/weixin/qr_handler.go
T
晨苒 e2e3b69acf feat(channel): add WeChat (weixin) adapter with QR code (#278)
* feat(channel): add WeChat (weixin) adapter with QR code

* fix(channel): fix weixin block streaming

* chore(channel): update weixin logo
2026-03-22 23:28:57 +08:00

174 lines
4.8 KiB
Go

package weixin
import (
"log/slog"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/channel"
)
// QRHandler handles WeChat QR code login for the management UI.
type QRHandler struct {
logger *slog.Logger
client *Client
lifecycle *channel.Lifecycle
}
// NewQRHandler creates a QR handler.
func NewQRHandler(log *slog.Logger, lifecycle *channel.Lifecycle) *QRHandler {
if log == nil {
log = slog.Default()
}
return &QRHandler{
logger: log.With(slog.String("handler", "weixin_qr")),
client: NewClient(log),
lifecycle: lifecycle,
}
}
// NewQRServerHandler is a DI-friendly constructor for fx, returning the handler
// that implements server.Handler.
func NewQRServerHandler(log *slog.Logger, lifecycle *channel.Lifecycle) *QRHandler {
return NewQRHandler(log, lifecycle)
}
// Register registers QR login routes on the Echo instance.
func (h *QRHandler) Register(e *echo.Echo) {
e.POST("/bots/:id/channel/weixin/qr/start", h.Start)
e.POST("/bots/:id/channel/weixin/qr/poll", h.Poll)
}
// QRStartResponse returns QR code data to the frontend.
type QRStartResponse struct {
QRCodeURL string `json:"qr_code_url"`
QRCode string `json:"qr_code"`
Message string `json:"message"`
}
// Start godoc
// @Summary Start WeChat QR login
// @Description Fetch a QR code from WeChat for scanning.
// @Tags bots
// @Param id path string true "Bot ID"
// @Success 200 {object} QRStartResponse
// @Failure 500 {object} map[string]string
// @Router /bots/{id}/channel/weixin/qr/start [post].
func (h *QRHandler) Start(c echo.Context) error {
qr, err := h.client.FetchQRCode(c.Request().Context(), defaultBaseURL)
if err != nil {
h.logger.Error("weixin qr start failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch QR code: "+err.Error())
}
return c.JSON(http.StatusOK, QRStartResponse{
QRCodeURL: strings.TrimSpace(qr.QRCodeImgContent),
QRCode: strings.TrimSpace(qr.QRCode),
Message: "Scan the QR code with WeChat",
})
}
// QRPollRequest is the request body for polling QR status.
type QRPollRequest struct {
QRCode string `json:"qr_code"`
}
// QRPollResponse returns the poll result.
type QRPollResponse struct {
Status string `json:"status"` // wait, scaned, confirmed, expired
Message string `json:"message"`
}
// Poll godoc
// @Summary Poll WeChat QR login status
// @Description Long-poll the QR code scan status. On confirmed, auto-saves credentials.
// @Tags bots
// @Param id path string true "Bot ID"
// @Param payload body QRPollRequest true "QR code to poll"
// @Success 200 {object} QRPollResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /bots/{id}/channel/weixin/qr/poll [post].
func (h *QRHandler) Poll(c echo.Context) error {
botID := strings.TrimSpace(c.Param("id"))
if botID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
}
var req QRPollRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
qrCode := strings.TrimSpace(req.QRCode)
if qrCode == "" {
return echo.NewHTTPError(http.StatusBadRequest, "qr_code is required")
}
status, err := h.client.PollQRStatus(c.Request().Context(), defaultBaseURL, qrCode)
if err != nil {
h.logger.Error("weixin qr poll failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "Poll failed: "+err.Error())
}
resp := QRPollResponse{
Status: status.Status,
Message: statusMessage(status.Status),
}
if status.Status == "confirmed" && strings.TrimSpace(status.BotToken) != "" {
resolvedBaseURL := defaultBaseURL
if strings.TrimSpace(status.BaseURL) != "" {
resolvedBaseURL = strings.TrimSpace(status.BaseURL)
}
if h.lifecycle != nil {
credentials := map[string]any{
"token": status.BotToken,
"baseUrl": resolvedBaseURL,
}
_, saveErr := h.lifecycle.UpsertBotChannelConfig(
c.Request().Context(),
botID,
Type,
channel.UpsertConfigRequest{
Credentials: credentials,
Disabled: boolPtr(false),
},
)
if saveErr != nil {
h.logger.Error("weixin qr save credentials failed",
slog.String("bot_id", botID),
slog.Any("error", saveErr),
)
return echo.NewHTTPError(http.StatusInternalServerError, "Login succeeded but failed to save credentials: "+saveErr.Error())
}
h.logger.Info("weixin qr login saved",
slog.String("bot_id", botID),
slog.String("account_id", status.ILinkBotID),
)
}
}
return c.JSON(http.StatusOK, resp)
}
func statusMessage(s string) string {
switch s {
case "wait":
return "Waiting for scan..."
case "scaned":
return "Scanned — confirm on your phone"
case "confirmed":
return "Login successful"
case "expired":
return "QR code expired"
default:
return s
}
}
func boolPtr(b bool) *bool { return &b }