From 69d39d906f8f25292a605f416ba2ed72489f76d6 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 12:17:05 +0800 Subject: [PATCH] feat(message-gateway): add admin channel CRUD APIs --- docs/docs.go | 423 ++++++++++++++++++ docs/swagger.json | 423 ++++++++++++++++++ docs/swagger.yaml | 259 +++++++++++ internal/apps/admin/message_gateway/errs.go | 14 + .../apps/admin/message_gateway/handlers.go | 157 +++++++ internal/apps/admin/message_gateway/logics.go | 346 ++++++++++++++ .../apps/admin/message_gateway/logics_test.go | 85 ++++ .../apps/admin/message_gateway/routers.go | 20 + internal/router/v1/admin.go | 1 + internal/router/v1/message_gateway_admin.go | 13 + 10 files changed, 1741 insertions(+) create mode 100644 internal/apps/admin/message_gateway/errs.go create mode 100644 internal/apps/admin/message_gateway/handlers.go create mode 100644 internal/apps/admin/message_gateway/logics.go create mode 100644 internal/apps/admin/message_gateway/logics_test.go create mode 100644 internal/apps/admin/message_gateway/routers.go create mode 100644 internal/router/v1/message_gateway_admin.go diff --git a/docs/docs.go b/docs/docs.go index 67371613..c98cead3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1183,6 +1183,295 @@ const docTemplate = `{ "responses": {} } }, + "/api/v1/admin/message-gateway/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns all messaging channels; secrets are masked", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "List message gateway channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.ChannelDTO" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Creates a Telegram or QQ channel with encrypted credentials", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Create message gateway channel", + "parameters": [ + { + "description": "create body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/message_gateway.CreateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/message_gateway.ChannelDTO" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/message-gateway/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns form field definitions for Telegram and QQ channels", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "List message gateway channel definitions", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.Definition" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/message-gateway/channels/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Deletes a channel and cascaded bindings and pairing codes", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Delete message gateway channel", + "parameters": [ + { + "type": "integer", + "description": "channel id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "patch": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Updates a channel; empty secrets keep the current ciphertext", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Update message gateway channel", + "parameters": [ + { + "type": "integer", + "description": "channel id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "update body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/message_gateway.UpdateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/message_gateway.ChannelDTO" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/message-gateway/channels/{id}/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Probes stored credentials without returning secrets", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Test message gateway channel", + "parameters": [ + { + "type": "integer", + "description": "channel id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/admin/push/channels": { "get": { "security": [ @@ -6143,6 +6432,140 @@ const docTemplate = `{ } } }, + "message_gateway.ChannelDTO": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "app_secret": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "bot_token": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "owner_scope": { + "type": "string" + }, + "portal_host": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "message_gateway.CreateChannelRequest": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "app_secret": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "bot_token": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "portal_host": { + "type": "string" + }, + "sandbox": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Definition": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.Field" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Field": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.UpdateChannelRequest": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "app_secret": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "bot_token": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "portal_host": { + "type": "string" + }, + "sandbox": { + "type": "string" + } + } + }, "model.AccessToken": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 1c4df5ed..08f794c0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1176,6 +1176,295 @@ "responses": {} } }, + "/api/v1/admin/message-gateway/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns all messaging channels; secrets are masked", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "List message gateway channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.ChannelDTO" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Creates a Telegram or QQ channel with encrypted credentials", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Create message gateway channel", + "parameters": [ + { + "description": "create body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/message_gateway.CreateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/message_gateway.ChannelDTO" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/message-gateway/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns form field definitions for Telegram and QQ channels", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "List message gateway channel definitions", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.Definition" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/message-gateway/channels/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Deletes a channel and cascaded bindings and pairing codes", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Delete message gateway channel", + "parameters": [ + { + "type": "integer", + "description": "channel id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "patch": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Updates a channel; empty secrets keep the current ciphertext", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Update message gateway channel", + "parameters": [ + { + "type": "integer", + "description": "channel id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "update body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/message_gateway.UpdateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/message_gateway.ChannelDTO" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/message-gateway/channels/{id}/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Probes stored credentials without returning secrets", + "produces": [ + "application/json" + ], + "tags": [ + "admin-message-gateway" + ], + "summary": "Test message gateway channel", + "parameters": [ + { + "type": "integer", + "description": "channel id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/admin/push/channels": { "get": { "security": [ @@ -6136,6 +6425,140 @@ } } }, + "message_gateway.ChannelDTO": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "app_secret": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "bot_token": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "owner_scope": { + "type": "string" + }, + "portal_host": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "message_gateway.CreateChannelRequest": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "app_secret": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "bot_token": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "portal_host": { + "type": "string" + }, + "sandbox": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Definition": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.Field" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Field": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.UpdateChannelRequest": { + "type": "object", + "properties": { + "app_id": { + "type": "string" + }, + "app_secret": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "bot_token": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "portal_host": { + "type": "string" + }, + "sandbox": { + "type": "string" + } + } + }, "model.AccessToken": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fc1b970c..90b95197 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -327,6 +327,94 @@ definitions: date: type: string type: object + message_gateway.ChannelDTO: + properties: + app_id: + type: string + app_secret: + type: string + base_url: + type: string + bot_token: + type: string + created_at: + type: string + enabled: + type: boolean + id: + example: "0" + type: string + name: + type: string + owner_scope: + type: string + portal_host: + type: string + type: + type: string + updated_at: + type: string + type: object + message_gateway.CreateChannelRequest: + properties: + app_id: + type: string + app_secret: + type: string + base_url: + type: string + bot_token: + type: string + enabled: + type: boolean + name: + type: string + portal_host: + type: string + sandbox: + type: string + type: + type: string + type: object + message_gateway.Definition: + properties: + fields: + items: + $ref: '#/definitions/message_gateway.Field' + type: array + name: + type: string + type: + type: string + type: object + message_gateway.Field: + properties: + key: + type: string + required: + type: boolean + type: + type: string + type: object + message_gateway.UpdateChannelRequest: + properties: + app_id: + type: string + app_secret: + type: string + base_url: + type: string + bot_token: + type: string + enabled: + type: boolean + name: + type: string + portal_host: + type: string + sandbox: + type: string + type: object model.AccessToken: properties: created_at: @@ -2139,6 +2227,177 @@ paths: summary: 系统日志实时推送 tags: - admin + /api/v1/admin/message-gateway/channels: + get: + description: Returns all messaging channels; secrets are masked + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/message_gateway.ChannelDTO' + type: array + type: object + security: + - SessionCookie: [] + summary: List message gateway channels + tags: + - admin-message-gateway + post: + consumes: + - application/json + description: Creates a Telegram or QQ channel with encrypted credentials + parameters: + - description: create body + in: body + name: request + required: true + schema: + $ref: '#/definitions/message_gateway.CreateChannelRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/message_gateway.ChannelDTO' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: Create message gateway channel + tags: + - admin-message-gateway + /api/v1/admin/message-gateway/channels/{id}: + delete: + description: Deletes a channel and cascaded bindings and pairing codes + parameters: + - description: channel id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: Delete message gateway channel + tags: + - admin-message-gateway + patch: + consumes: + - application/json + description: Updates a channel; empty secrets keep the current ciphertext + parameters: + - description: channel id + in: path + name: id + required: true + type: integer + - description: update body + in: body + name: request + required: true + schema: + $ref: '#/definitions/message_gateway.UpdateChannelRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/message_gateway.ChannelDTO' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: Update message gateway channel + tags: + - admin-message-gateway + /api/v1/admin/message-gateway/channels/{id}/test: + post: + description: Probes stored credentials without returning secrets + parameters: + - description: channel id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: Test message gateway channel + tags: + - admin-message-gateway + /api/v1/admin/message-gateway/channels/definitions: + get: + description: Returns form field definitions for Telegram and QQ channels + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/message_gateway.Definition' + type: array + type: object + security: + - SessionCookie: [] + summary: List message gateway channel definitions + tags: + - admin-message-gateway /api/v1/admin/push/channels: get: description: 返回系统配置的所有消息通道列表,需要管理员权限 diff --git a/internal/apps/admin/message_gateway/errs.go b/internal/apps/admin/message_gateway/errs.go new file mode 100644 index 00000000..7d2ddb3e --- /dev/null +++ b/internal/apps/admin/message_gateway/errs.go @@ -0,0 +1,14 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +const ( + errNameRequired = "name is required" + errTypeInvalid = "type must be telegram or qq" + errTelegramTokenRequired = "bot_token is required" + errQQCredentialsRequired = "app_id and app_secret are required" + errChannelNotFound = "channel not found" + errChannelProbeFailed = "channel probe failed" + maskedSecret = "********" +) diff --git a/internal/apps/admin/message_gateway/handlers.go b/internal/apps/admin/message_gateway/handlers.go new file mode 100644 index 00000000..402dae19 --- /dev/null +++ b/internal/apps/admin/message_gateway/handlers.go @@ -0,0 +1,157 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "net/http" + "strconv" + + "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/gin-gonic/gin" +) + +// ListChannelDefinitions returns form schemas for supported channel types. +// @Summary List message gateway channel definitions +// @Description Returns form field definitions for Telegram and QQ channels +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]Definition} +// @Router /api/v1/admin/message-gateway/channels/definitions [get] +func ListChannelDefinitions(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(channelDefinitions())) +} + +// ListChannels lists configured messaging channels with secrets masked. +// @Summary List message gateway channels +// @Description Returns all messaging channels; secrets are masked +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]ChannelDTO} +// @Router /api/v1/admin/message-gateway/channels [get] +func ListChannels(c *gin.Context) { + rows, err := listChannels(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(rows)) +} + +// CreateChannel creates a messaging channel. +// @Summary Create message gateway channel +// @Description Creates a Telegram or QQ channel with encrypted credentials +// @Tags admin-message-gateway +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body CreateChannelRequest true "create body" +// @Success 200 {object} response.Any{data=ChannelDTO} +// @Failure 400 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels [post] +func CreateChannel(c *gin.Context) { + var req CreateChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + dto, err := createChannel(c.Request.Context(), req) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(dto)) +} + +// UpdateChannel patches a messaging channel. Empty secrets keep the previous values. +// @Summary Update message gateway channel +// @Description Updates a channel; empty secrets keep the current ciphertext +// @Tags admin-message-gateway +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path int true "channel id" +// @Param request body UpdateChannelRequest true "update body" +// @Success 200 {object} response.Any{data=ChannelDTO} +// @Failure 400 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels/{id} [patch] +func UpdateChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, "invalid channel id") + return + } + var req UpdateChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + dto, err := updateChannel(c.Request.Context(), id, req) + if err != nil { + if err.Error() == errChannelNotFound { + response.AbortNotFound(c, err.Error()) + return + } + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(dto)) +} + +// DeleteChannel removes a channel and its bindings/pairing codes. +// @Summary Delete message gateway channel +// @Description Deletes a channel and cascaded bindings and pairing codes +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Param id path int true "channel id" +// @Success 200 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels/{id} [delete] +func DeleteChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, "invalid channel id") + return + } + if err := deleteChannel(c.Request.Context(), id); err != nil { + if err.Error() == errChannelNotFound { + response.AbortNotFound(c, err.Error()) + return + } + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// TestChannel probes stored credentials (Telegram getMe or QQ token). +// @Summary Test message gateway channel +// @Description Probes stored credentials without returning secrets +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Param id path int true "channel id" +// @Success 200 {object} response.Any +// @Failure 400 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels/{id}/test [post] +func TestChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, "invalid channel id") + return + } + if err := probeChannel(c.Request.Context(), id); err != nil { + if err.Error() == errChannelNotFound { + response.AbortNotFound(c, err.Error()) + return + } + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/internal/apps/admin/message_gateway/logics.go b/internal/apps/admin/message_gateway/logics.go new file mode 100644 index 00000000..ee2f726b --- /dev/null +++ b/internal/apps/admin/message_gateway/logics.go @@ -0,0 +1,346 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + appgw "github.com/Rain-kl/Wavelet/internal/apps/message_gateway" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/tencent-connect/botgo/token" + "gorm.io/gorm" +) + +const defaultTelegramAPI = "https://api.telegram.org" + +// Field is one admin form field. +type Field struct { + Key string `json:"key"` + Type string `json:"type"` + Required bool `json:"required"` +} + +// Definition describes a channel type form. +type Definition struct { + Type string `json:"type"` + Name string `json:"name"` + Fields []Field `json:"fields"` +} + +// CreateChannelRequest is the admin create body. +type CreateChannelRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled *bool `json:"enabled"` + BotToken string `json:"bot_token"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + BaseURL string `json:"base_url"` + PortalHost string `json:"portal_host"` + Sandbox string `json:"sandbox"` +} + +// UpdateChannelRequest is the admin patch body. +type UpdateChannelRequest struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + BotToken string `json:"bot_token"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + BaseURL *string `json:"base_url"` + PortalHost *string `json:"portal_host"` + Sandbox *string `json:"sandbox"` +} + +// ChannelDTO is a list/detail view with secrets masked. +type ChannelDTO struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` + OwnerScope string `json:"owner_scope"` + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token,omitempty"` + AppID string `json:"app_id,omitempty"` + AppSecret string `json:"app_secret,omitempty"` + BaseURL string `json:"base_url,omitempty"` + PortalHost string `json:"portal_host,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func channelDefinitions() []Definition { + return []Definition{ + { + Type: model.MessageChannelTypeTelegram, + Name: "Telegram", + Fields: []Field{ + {Key: "bot_token", Type: "password", Required: true}, + {Key: "base_url", Type: "text"}, + }, + }, + { + Type: model.MessageChannelTypeQQ, + Name: "QQ", + Fields: []Field{ + {Key: "app_id", Required: true}, + {Key: "app_secret", Type: "password", Required: true}, + {Key: "portal_host", Type: "text"}, + }, + }, + } +} + +func createChannel(ctx context.Context, req CreateChannelRequest) (ChannelDTO, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return ChannelDTO{}, errors.New(errNameRequired) + } + typ := strings.TrimSpace(req.Type) + creds, extra, err := credentialsFromCreate(req) + if err != nil { + return ChannelDTO{}, err + } + cipher, err := appgw.EncryptCredentials(creds) + if err != nil { + return ChannelDTO{}, err + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + row := &model.MessageChannel{ + Name: name, + Type: typ, + OwnerScope: model.MessageOwnerScopeSystem, + Enabled: enabled, + Credentials: cipher, + Extra: appgw.EncodeExtra(extra), + } + if err := repository.CreateMessageChannel(ctx, row); err != nil { + return ChannelDTO{}, err + } + return toDTO(row, creds, extra), nil +} + +func updateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (ChannelDTO, error) { + row, err := repository.GetMessageChannel(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ChannelDTO{}, errors.New(errChannelNotFound) + } + return ChannelDTO{}, err + } + creds, err := appgw.DecryptCredentials(row.Credentials) + if err != nil { + creds = map[string]string{} + } + extra := appgw.ParseExtra(row.Extra) + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return ChannelDTO{}, errors.New(errNameRequired) + } + row.Name = name + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if token := strings.TrimSpace(req.BotToken); token != "" { + creds["bot_token"] = token + } + if appID := strings.TrimSpace(req.AppID); appID != "" { + creds["app_id"] = appID + } + if secret := strings.TrimSpace(req.AppSecret); secret != "" { + creds["app_secret"] = secret + } + if req.BaseURL != nil { + extra["base_url"] = strings.TrimSpace(*req.BaseURL) + } + if req.PortalHost != nil { + extra["portal_host"] = strings.TrimSpace(*req.PortalHost) + } + if req.Sandbox != nil { + extra["sandbox"] = strings.TrimSpace(*req.Sandbox) + } + if err := validateCredentials(row.Type, creds); err != nil { + return ChannelDTO{}, err + } + cipher, err := appgw.EncryptCredentials(creds) + if err != nil { + return ChannelDTO{}, err + } + row.Credentials = cipher + row.Extra = appgw.EncodeExtra(extra) + if err := repository.UpdateMessageChannel(ctx, row); err != nil { + return ChannelDTO{}, err + } + return toDTO(row, creds, extra), nil +} + +func listChannels(ctx context.Context) ([]ChannelDTO, error) { + rows, err := repository.ListMessageChannels(ctx) + if err != nil { + return nil, err + } + out := make([]ChannelDTO, 0, len(rows)) + for i := range rows { + creds, err := appgw.DecryptCredentials(rows[i].Credentials) + if err != nil { + creds = map[string]string{} + } + out = append(out, toDTO(&rows[i], creds, appgw.ParseExtra(rows[i].Extra))) + } + return out, nil +} + +func deleteChannel(ctx context.Context, id uint64) error { + if _, err := repository.GetMessageChannel(ctx, id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(errChannelNotFound) + } + return err + } + return repository.DeleteMessageChannel(ctx, id) +} + +func probeChannel(ctx context.Context, id uint64) error { + row, err := repository.GetMessageChannel(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(errChannelNotFound) + } + return err + } + creds, err := appgw.DecryptCredentials(row.Credentials) + if err != nil { + return err + } + extra := appgw.ParseExtra(row.Extra) + if err := probeCredentials(ctx, row.Type, creds, extra); err != nil { + return fmt.Errorf("%s: %w", errChannelProbeFailed, err) + } + return nil +} + +func credentialsFromCreate(req CreateChannelRequest) (map[string]string, map[string]string, error) { + typ := strings.TrimSpace(req.Type) + creds := map[string]string{} + extra := map[string]string{} + switch typ { + case model.MessageChannelTypeTelegram: + creds["bot_token"] = strings.TrimSpace(req.BotToken) + if base := strings.TrimSpace(req.BaseURL); base != "" { + extra["base_url"] = base + } + case model.MessageChannelTypeQQ: + creds["app_id"] = strings.TrimSpace(req.AppID) + creds["app_secret"] = strings.TrimSpace(req.AppSecret) + if host := strings.TrimSpace(req.PortalHost); host != "" { + extra["portal_host"] = host + } else { + extra["portal_host"] = "q.qq.com" + } + if sandbox := strings.TrimSpace(req.Sandbox); sandbox != "" { + extra["sandbox"] = sandbox + } + default: + return nil, nil, errors.New(errTypeInvalid) + } + if err := validateCredentials(typ, creds); err != nil { + return nil, nil, err + } + return creds, extra, nil +} + +func validateCredentials(typ string, creds map[string]string) error { + switch typ { + case model.MessageChannelTypeTelegram: + if strings.TrimSpace(creds["bot_token"]) == "" { + return errors.New(errTelegramTokenRequired) + } + case model.MessageChannelTypeQQ: + if strings.TrimSpace(creds["app_id"]) == "" || strings.TrimSpace(creds["app_secret"]) == "" { + return errors.New(errQQCredentialsRequired) + } + default: + return errors.New(errTypeInvalid) + } + return nil +} + +func toDTO(row *model.MessageChannel, creds, extra map[string]string) ChannelDTO { + dto := ChannelDTO{ + ID: row.ID, + Name: row.Name, + Type: row.Type, + OwnerScope: row.OwnerScope, + Enabled: row.Enabled, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if strings.TrimSpace(creds["bot_token"]) != "" { + dto.BotToken = maskedSecret + } + if id := strings.TrimSpace(creds["app_id"]); id != "" { + dto.AppID = id + } + if strings.TrimSpace(creds["app_secret"]) != "" { + dto.AppSecret = maskedSecret + } + dto.BaseURL = extra["base_url"] + dto.PortalHost = extra["portal_host"] + return dto +} + +func probeCredentials(ctx context.Context, typ string, creds, extra map[string]string) error { + switch typ { + case model.MessageChannelTypeTelegram: + base := strings.TrimSpace(extra["base_url"]) + if base == "" { + base = defaultTelegramAPI + } + url := strings.TrimRight(base, "/") + "/bot" + creds["bot_token"] + "/getMe" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("telegram getMe status %d", resp.StatusCode) + } + var parsed struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return err + } + if !parsed.OK { + return errors.New("telegram getMe returned ok=false") + } + return nil + case model.MessageChannelTypeQQ: + src := token.NewQQBotTokenSource(&token.QQBotCredentials{ + AppID: creds["app_id"], + AppSecret: creds["app_secret"], + }) + _, err := src.Token() + return err + default: + return errors.New(errTypeInvalid) + } +} diff --git a/internal/apps/admin/message_gateway/logics_test.go b/internal/apps/admin/message_gateway/logics_test.go new file mode 100644 index 00000000..334718b9 --- /dev/null +++ b/internal/apps/admin/message_gateway/logics_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "strings" + "testing" + + appgw "github.com/Rain-kl/Wavelet/internal/apps/message_gateway" + "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/internal/testhelper" +) + +func TestCreateChannel_TelegramRequiresToken(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + _, err := createChannel(context.Background(), CreateChannelRequest{Name: "tg", Type: "telegram"}) + if err == nil { + t.Fatal("createChannel() error = nil, want token required") + } +} + +func TestCreateChannel_StoresCiphertextNotPlaintext(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + const token = "secret-token-xyz" + dto, err := createChannel(context.Background(), CreateChannelRequest{ + Name: "tg", + Type: "telegram", + BotToken: token, + }) + if err != nil { + t.Fatalf("createChannel() error = %v", err) + } + if strings.Contains(dto.BotToken, token) { + t.Fatalf("createChannel() dto leaked plaintext token %q", dto.BotToken) + } + row, err := repository.GetMessageChannel(context.Background(), dto.ID) + if err != nil { + t.Fatalf("GetMessageChannel() error = %v", err) + } + if strings.Contains(row.Credentials, token) { + t.Fatalf("stored credentials contain plaintext token") + } + creds, err := appgw.DecryptCredentials(row.Credentials) + if err != nil { + t.Fatalf("DecryptCredentials() error = %v", err) + } + if creds["bot_token"] != token { + t.Fatalf("DecryptCredentials() bot_token = %q, want %q", creds["bot_token"], token) + } +} + +func TestUpdateChannel_EmptySecretKeepsPrevious(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + created, err := createChannel(context.Background(), CreateChannelRequest{ + Name: "tg", + Type: "telegram", + BotToken: "old-token", + }) + if err != nil { + t.Fatalf("createChannel() error = %v", err) + } + name := "renamed" + if _, err := updateChannel(context.Background(), created.ID, UpdateChannelRequest{Name: &name}); err != nil { + t.Fatalf("updateChannel() error = %v", err) + } + row, err := repository.GetMessageChannel(context.Background(), created.ID) + if err != nil { + t.Fatalf("GetMessageChannel() error = %v", err) + } + if row.Name != "renamed" { + t.Fatalf("updateChannel() name = %q, want renamed", row.Name) + } + creds, err := appgw.DecryptCredentials(row.Credentials) + if err != nil { + t.Fatalf("DecryptCredentials() error = %v", err) + } + if creds["bot_token"] != "old-token" { + t.Fatalf("updateChannel() bot_token = %q, want old-token", creds["bot_token"]) + } +} diff --git a/internal/apps/admin/message_gateway/routers.go b/internal/apps/admin/message_gateway/routers.go new file mode 100644 index 00000000..6005f5e6 --- /dev/null +++ b/internal/apps/admin/message_gateway/routers.go @@ -0,0 +1,20 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package message_gateway provides admin HTTP APIs for messaging channels. +package message_gateway + +import "github.com/gin-gonic/gin" + +// RegisterRoutes mounts admin message-gateway APIs under /admin. +func RegisterRoutes(adminRouter *gin.RouterGroup) { + g := adminRouter.Group("/message-gateway") + { + g.GET("/channels/definitions", ListChannelDefinitions) + g.GET("/channels", ListChannels) + g.POST("/channels", CreateChannel) + g.PATCH("/channels/:id", UpdateChannel) + g.DELETE("/channels/:id", DeleteChannel) + g.POST("/channels/:id/test", TestChannel) + } +} diff --git a/internal/router/v1/admin.go b/internal/router/v1/admin.go index 043cb162..1f908ca9 100644 --- a/internal/router/v1/admin.go +++ b/internal/router/v1/admin.go @@ -44,6 +44,7 @@ func RegisterAdminRoutes(apiV1Router *gin.RouterGroup) { // 6. Messaging & Push Notifications registerAdminPushRoutes(adminRouter) + registerAdminMessageGatewayRoutes(adminRouter) } } diff --git a/internal/router/v1/message_gateway_admin.go b/internal/router/v1/message_gateway_admin.go new file mode 100644 index 00000000..58f3b412 --- /dev/null +++ b/internal/router/v1/message_gateway_admin.go @@ -0,0 +1,13 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + adminmsg "github.com/Rain-kl/Wavelet/internal/apps/admin/message_gateway" + "github.com/gin-gonic/gin" +) + +func registerAdminMessageGatewayRoutes(adminRouter *gin.RouterGroup) { + adminmsg.RegisterRoutes(adminRouter) +}