feat(message-gateway): add admin channel CRUD APIs

This commit is contained in:
ryan
2026-08-16 12:17:05 +08:00
parent 09ec9d0af3
commit 69d39d906f
10 changed files with 1741 additions and 0 deletions
+423
View File
@@ -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": {
+423
View File
@@ -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": {
+259
View File
@@ -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: 返回系统配置的所有消息通道列表,需要管理员权限
@@ -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 = "********"
)
@@ -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())
}
@@ -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)
}
}
@@ -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"])
}
}
@@ -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)
}
}
+1
View File
@@ -44,6 +44,7 @@ func RegisterAdminRoutes(apiV1Router *gin.RouterGroup) {
// 6. Messaging & Push Notifications
registerAdminPushRoutes(adminRouter)
registerAdminMessageGatewayRoutes(adminRouter)
}
}
@@ -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)
}