diff --git a/channel/telegram.go b/channel/telegram.go index fab9a3b..ba82d69 100644 --- a/channel/telegram.go +++ b/channel/telegram.go @@ -23,7 +23,7 @@ type telegramMessageResponse struct { func SendTelegramMessage(message *model.Message, user *model.User, channel_ *model.Channel) error { // https://core.telegram.org/bots/api#sendmessage messageRequest := telegramMessageRequest{ - ChatId: channel_.AppId, + ChatId: channel_.AccountId, Text: message.Content, ParseMode: "markdown", } diff --git a/common/utils.go b/common/utils.go index 39615c9..878011d 100644 --- a/common/utils.go +++ b/common/utils.go @@ -12,6 +12,7 @@ import ( "runtime" "strconv" "strings" + "time" ) func OpenBrowser(url string) { @@ -151,3 +152,7 @@ func Markdown2HTML(markdown string) (HTML string, err error) { HTML = buf.String() return } + +func GetTimestamp() int64 { + return time.Now().Unix() +} diff --git a/controller/channel.go b/controller/channel.go index d672407..dfe6ccb 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -94,8 +94,17 @@ func AddChannel(c *gin.Context) { return } cleanChannel := model.Channel{ - UserId: c.GetInt("id"), - Name: channel_.Name, + Type: channel_.Type, + UserId: c.GetInt("id"), + Name: channel_.Name, + Description: channel_.Description, + Status: common.ChannelStatusEnabled, + Secret: channel_.Secret, + AppId: channel_.AppId, + AccountId: channel_.AccountId, + URL: channel_.URL, + Other: channel_.Other, + CreatedTime: common.GetTimestamp(), } err = cleanChannel.Insert() if err != nil { @@ -159,6 +168,7 @@ func UpdateChannel(c *gin.Context) { // If you add more fields, please also update channel_.Update() cleanChannel.Type = channel_.Type cleanChannel.Name = channel_.Name + cleanChannel.Description = channel_.Description cleanChannel.Secret = channel_.Secret cleanChannel.AppId = channel_.AppId cleanChannel.AccountId = channel_.AccountId diff --git a/controller/message.go b/controller/message.go index 5ceb39e..706205f 100644 --- a/controller/message.go +++ b/controller/message.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "fmt" "github.com/gin-gonic/gin" "message-pusher/channel" @@ -136,6 +137,9 @@ func pushMessageHelper(c *gin.Context, message *model.Message) { } func saveAndSendMessage(user *model.User, message *model.Message, channel_ *model.Channel) error { + if channel_.Status != common.ChannelStatusEnabled { + return errors.New("该渠道已被禁用") + } message.Link = common.GetUUID() if message.URL == "" { message.URL = fmt.Sprintf("%s/message/%s", common.ServerAddress, message.Link) diff --git a/model/channel.go b/model/channel.go index c4f588a..ed9e863 100644 --- a/model/channel.go +++ b/model/channel.go @@ -19,16 +19,18 @@ const ( ) type Channel struct { - Id int `json:"id"` - Type string `json:"type" gorm:"type:varchar(32)"` - UserId int `json:"user_id" gorm:"uniqueIndex:name_user_id"` - Name string `json:"name" gorm:"type:varchar(32);uniqueIndex:name_user_id"` - Status int `json:"status" gorm:"default:1"` // enabled, disabled - Secret string `json:"secret"` - AppId string `json:"app_id"` - AccountId string `json:"account_id"` - URL string `json:"url" gorm:"column:url"` - Other string `json:"other"` + Id int `json:"id"` + Type string `json:"type" gorm:"type:varchar(32)"` + UserId int `json:"user_id" gorm:"uniqueIndex:name_user_id"` + Name string `json:"name" gorm:"type:varchar(32);uniqueIndex:name_user_id"` + Description string `json:"description"` + Status int `json:"status" gorm:"default:1"` // enabled, disabled + Secret string `json:"secret"` + AppId string `json:"app_id"` + AccountId string `json:"account_id"` + URL string `json:"url" gorm:"column:url"` + Other string `json:"other"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` } func GetChannelById(id int, userId int) (*Channel, error) { @@ -60,12 +62,12 @@ func GetTokenStoreChannelsByUserId(userId int) (channels []*Channel, err error) } func GetChannelsByUserId(userId int, startIdx int, num int) (channels []*Channel, err error) { - err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&channels).Error + err = DB.Omit("secret").Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&channels).Error return channels, err } func SearchChannels(userId int, keyword string) (channels []*Channel, err error) { - err = DB.Where("user_id = ?", userId).Select([]string{"id", "name"}).Where("id = ? or name LIKE", keyword, keyword+"%").Find(&channels).Error + err = DB.Omit("secret").Where("user_id = ?", userId).Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&channels).Error return channels, err } @@ -96,7 +98,7 @@ func (channel *Channel) UpdateStatus(status int) error { // Update Make sure your token's fields is completed, because this will update non-zero values func (channel *Channel) Update() error { var err error - err = DB.Model(channel).Select("type", "name", "secret", "app_id", "account_id", "url", "other").Updates(channel).Error + err = DB.Model(channel).Select("type", "name", "description", "secret", "app_id", "account_id", "url", "other", "status").Updates(channel).Error return err } diff --git a/router/api-router.go b/router/api-router.go index a96eafe..515c1f3 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -64,7 +64,7 @@ func SetApiRouter(router *gin.Engine) { messageRoute.DELETE("/", middleware.RootAuth(), controller.DeleteAllMessages) messageRoute.DELETE("/:id", middleware.UserAuth(), controller.DeleteMessage) } - channelRoute := apiRouter.Group("/token") + channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.UserAuth()) { channelRoute.GET("/", controller.GetAllChannels) diff --git a/web/src/App.js b/web/src/App.js index c11a444..aa0d38c 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -16,6 +16,8 @@ import PasswordResetConfirm from './components/PasswordResetConfirm'; import { UserContext } from './context/User'; import { StatusContext } from './context/Status'; import Message from './pages/Message'; +import Channel from './pages/Channel'; +import EditChannel from './pages/Channel/EditChannel'; const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); @@ -107,6 +109,30 @@ function App() { } /> + + + + } + /> + }> + + + } + /> + }> + + + } + /> { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [searchKeyword, setSearchKeyword] = useState(''); + const [searching, setSearching] = useState(false); + const [user, setUser] = useState({ username: '', token: '' }); + + const loadChannels = async (startIdx) => { + const res = await API.get(`/api/channel/?p=${startIdx}`); + const { success, message, data } = res.data; + if (success) { + if (startIdx === 0) { + setChannels(data); + } else { + let newChannels = channels; + newChannels.push(...data); + setChannels(newChannels); + } + } else { + showError(message); + } + setLoading(false); + }; + + const onPaginationChange = (e, { activePage }) => { + (async () => { + if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE) + 1) { + // In this case we have to load more data and then append them. + await loadChannels(activePage - 1); + } + setActivePage(activePage); + })(); + }; + + useEffect(() => { + loadChannels(0) + .then() + .catch((reason) => { + showError(reason); + }); + loadUser() + .then() + .catch((reason) => { + showError(reason); + }); + }, []); + + const manageChannel = async (id, action, idx) => { + let data = { id }; + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/channel/${id}/`); + break; + case 'enable': + data.status = 1; + res = await API.put('/api/channel/?status_only=true', data); + break; + case 'disable': + data.status = 2; + res = await API.put('/api/channel/?status_only=true', data); + break; + } + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let channel = res.data.data; + let newChannels = [...channels]; + let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; + if (action === 'delete') { + newChannels[realIdx].deleted = true; + } else { + newChannels[realIdx].status = channel.status; + } + setChannels(newChannels); + } else { + showError(message); + } + }; + + const renderStatus = (status) => { + switch (status) { + case 1: + return ; + case 2: + return ( + + ); + default: + return ( + + ); + } + }; + + const searchChannels = async () => { + if (searchKeyword === '') { + // if keyword is blank, load files instead. + await loadChannels(0); + setActivePage(1); + return; + } + setSearching(true); + const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`); + const { success, message, data } = res.data; + if (success) { + setChannels(data); + setActivePage(1); + } else { + showError(message); + } + setSearching(false); + }; + + const handleKeywordChange = async (e, { value }) => { + setSearchKeyword(value.trim()); + }; + + const sortChannel = (key) => { + if (channels.length === 0) return; + setLoading(true); + let sortedChannels = [...channels]; + sortedChannels.sort((a, b) => { + return ('' + a[key]).localeCompare(b[key]); + }); + if (sortedChannels[0].id === channels[0].id) { + sortedChannels.reverse(); + } + setChannels(sortedChannels); + setLoading(false); + }; + + const loadUser = async () => { + let res = await API.get(`/api/user/self`); + const { success, message, data } = res.data; + if (success) { + setUser(data); + } else { + showError(message); + } + setLoading(false); + }; + + const test = async (channel) => { + let res = await API.get( + `/push/${user.username}?token=${user.token}&channel=${channel}&title=消息推送服务&description=配置成功!` + ); + const { success, message } = res.data; + if (success) { + showSuccess('测试消息已发送'); + } else { + showError(message); + } + }; + + return ( + <> +
+ + + + + + + { + sortChannel('id'); + }} + > + ID + + { + sortChannel('name'); + }} + > + 名称 + + { + sortChannel('description'); + }} + > + 备注 + + { + sortChannel('type'); + }} + > + 类型 + + { + sortChannel('status'); + }} + > + 状态 + + { + sortChannel('created_time'); + }} + > + 创建时间 + + 操作 + + + + + {channels + .slice( + (activePage - 1) * ITEMS_PER_PAGE, + activePage * ITEMS_PER_PAGE + ) + .map((channel, idx) => { + if (channel.deleted) return <>; + return ( + + {channel.id} + {channel.name} + + {channel.description ? channel.description : '无备注信息'} + + {renderChannel(channel.type)} + {renderStatus(channel.status)} + + {renderTimestamp(channel.created_time)} + + +
+ + + + +
+
+
+ ); + })} +
+ + + + + + + + + +
+ + ); +}; + +export default ChannelsTable; diff --git a/web/src/components/Header.js b/web/src/components/Header.js index ec2b16c..eabfa9e 100644 --- a/web/src/components/Header.js +++ b/web/src/components/Header.js @@ -18,6 +18,11 @@ const headerButtons = [ to: '/message', icon: 'mail', }, + { + name: '通道', + to: '/channel', + icon: 'sitemap', + }, { name: '用户', to: '/user', diff --git a/web/src/components/MessagesTable.js b/web/src/components/MessagesTable.js index cd65600..5378a87 100644 --- a/web/src/components/MessagesTable.js +++ b/web/src/components/MessagesTable.js @@ -1,81 +1,16 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Button, Form, Label, Modal, Pagination, Table } from 'semantic-ui-react'; -import { API, openPage, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; +import { + Button, + Form, + Label, + Modal, + Pagination, + Table, +} from 'semantic-ui-react'; +import { API, openPage, showError, showSuccess, showWarning } from '../helpers'; import { ITEMS_PER_PAGE } from '../constants'; - -function renderChannel(channel) { - switch (channel) { - case 'email': - return ; - case 'test': - return ( - - ); - case 'corp_app': - return ( - - ); - case 'corp': - return ( - - ); - case 'lark': - return ( - - ); - case 'ding': - return ( - - ); - case 'bark': - return ( - - ); - case 'client': - return ( - - ); - case 'telegram': - return ( - - ); - case 'discord': - return ( - - ); - case 'none': - return ; - default: - return ; - } -} - -function renderTimestamp(timestamp) { - return ( - <> - {timestamp2string(timestamp)} - - ); -} +import { renderChannel, renderTimestamp } from '../helpers/render'; function renderStatus(status) { switch (status) { @@ -119,8 +54,8 @@ const MessagesTable = () => { title: '消息标题', description: '消息描述', content: '消息内容', - link: '' - }); // Message to be viewed + link: '', + }); // Message to be viewed const [viewModalOpen, setViewModalOpen] = useState(false); const loadMessages = async (startIdx) => { @@ -277,7 +212,7 @@ const MessagesTable = () => { autoRefreshSecondsRef.current = 10; } else { autoRefreshSecondsRef.current -= 1; - setAutoRefreshSeconds(autoRefreshSeconds => autoRefreshSeconds - 1); // Important! + setAutoRefreshSeconds((autoRefreshSeconds) => autoRefreshSeconds - 1); // Important! } }, 1000); } @@ -285,7 +220,6 @@ const MessagesTable = () => { return () => clearInterval(intervalId); }, [autoRefresh]); - return ( <>
@@ -405,16 +339,26 @@ const MessagesTable = () => { - - { - + {message.title ? message.title : '无标题'} - {message.description ?

{message.description}

: ''} + {message.description ? ( +

{message.description}

+ ) : ( + '' + )} {message.content ?

{message.content}

: ''}
- - diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js new file mode 100644 index 0000000..f2f4449 --- /dev/null +++ b/web/src/constants/channel.constants.js @@ -0,0 +1,38 @@ +export const CHANNEL_OPTIONS = [ + { key: 'email', text: '邮件', value: 'email', color: '#4285f4' }, + { key: 'test', text: '微信测试号', value: 'test', color: '#2cbb00' }, + { + key: 'corp_app', + text: '企业微信应用号', + value: 'corp_app', + color: '#5fc9ec', + }, + { key: 'corp', text: '企业微信群机器人', value: 'corp', color: '#019d82' }, + { key: 'lark', text: '飞书群机器人', value: 'lark', color: '#00d6b9' }, + { key: 'ding', text: '钉钉群机器人', value: 'ding', color: '#007fff' }, + { key: 'bark', text: 'Bark App', value: 'bark', color: '#ff3b30' }, + { + key: 'client', + text: 'WebSocket 客户端', + value: 'client', + color: '#121212', + }, + { + key: 'telegram', + text: 'Telegram 机器人', + value: 'telegram', + color: '#29a9ea', + }, + { + key: 'discord', + text: 'Discord 群机器人', + value: 'discord', + color: '#404eed', + }, + { + key: 'none', + text: '不推送', + value: 'none', + color: '#808080', + }, +]; diff --git a/web/src/constants/index.js b/web/src/constants/index.js index 8f9ba80..e83152b 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/index.js @@ -1,3 +1,4 @@ export * from './toast.constants'; export * from './user.constants'; -export * from './common.constant'; \ No newline at end of file +export * from './common.constant'; +export * from './channel.constants'; \ No newline at end of file diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js new file mode 100644 index 0000000..54aa406 --- /dev/null +++ b/web/src/helpers/render.js @@ -0,0 +1,32 @@ +import { Label } from 'semantic-ui-react'; +import { timestamp2string } from './utils'; +import React from 'react'; +import { CHANNEL_OPTIONS } from '../constants'; + +let channelMap = undefined; + +export function renderChannel(key) { + if (channelMap === undefined) { + channelMap = new Map(); + CHANNEL_OPTIONS.forEach((option) => { + channelMap[option.key] = option; + }); + } + let channel = channelMap[key]; + if (channel) { + return ( + + ); + } + return ( + + ); +} + +export function renderTimestamp(timestamp) { + return <>{timestamp2string(timestamp)}; +} diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js new file mode 100644 index 0000000..1c1dc12 --- /dev/null +++ b/web/src/pages/Channel/EditChannel.js @@ -0,0 +1,520 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Form, Header, Message, Segment } from 'semantic-ui-react'; +import { useParams } from 'react-router-dom'; +import { API, showError, showSuccess } from '../../helpers'; +import { CHANNEL_OPTIONS } from '../../constants'; +import axios from 'axios'; + +const EditChannel = () => { + const params = useParams(); + const channelId = params.id; + const isEditing = channelId !== undefined; + const [loading, setLoading] = useState(isEditing); + const originInputs = { + type: 'none', + name: '', + description: '', + secret: '', + app_id: '', + account_id: '', + url: '', + other: '', + corp_id: '', // only for corp_app + agent_id: '', // only for corp_app + }; + + const [inputs, setInputs] = useState(originInputs); + const { type, name, description, secret, app_id, account_id, url, other } = + inputs; + + const handleInputChange = (e, { name, value }) => { + setInputs((inputs) => ({ ...inputs, [name]: value })); + }; + + const loadChannel = async () => { + let res = await API.get(`/api/channel/${channelId}`); + const { success, message, data } = res.data; + if (success) { + if (data.type === 'corp_app') { + const [corp_id, agent_id] = data.app_id.split('|'); + data.corp_id = corp_id; + data.agent_id = agent_id; + } + setInputs(data); + } else { + showError(message); + } + setLoading(false); + }; + useEffect(() => { + if (isEditing) { + loadChannel().then(); + } + }, []); + + const submit = async () => { + if (!name) return; + let res = undefined; + let localInputs = { ...inputs }; + switch (inputs.type) { + case 'corp_app': + localInputs.app_id = `${inputs.corp_id}|${inputs.agent_id}`; + break; + case 'bark': + localInputs.url = 'https://api.day.app'; + } + if (isEditing) { + res = await API.put(`/api/channel/`, { + ...localInputs, + id: parseInt(channelId), + }); + } else { + res = await API.post(`/api/channel`, localInputs); + } + const { success, message } = res.data; + if (success) { + if (isEditing) { + showSuccess('通道信息更新成功!'); + } else { + showSuccess('通道创建成功!'); + setInputs(originInputs); + } + } else { + showError(message); + } + }; + + const getTelegramChatId = async () => { + if (inputs.telegram_bot_token === '') { + showError('请先输入 Telegram 机器人令牌!'); + return; + } + let res = await axios.get( + `https://api.telegram.org/bot${inputs.secret}/getUpdates` + ); + const { ok } = res.data; + if (ok) { + let result = res.data.result; + if (result.length === 0) { + showError(`请先向你的机器人发送一条任意消息!`); + } else { + let id = result[0]?.message?.chat?.id; + id = id.toString(); + setInputs((inputs) => ({ ...inputs, account_id: id })); + showSuccess('会话 ID 获取成功!'); + } + } else { + showError(`发生错误:${res.description}`); + } + }; + + const renderChannelForm = () => { + switch (type) { + case 'email': + return ( + <> + + 邮件推送方式(email)需要设置邮箱,请前往个人设置页面绑定邮箱地址,之后系统将自动为你创建邮箱推送通道。 + + + ); + case 'test': + return ( + <> + + 通过微信测试号进行推送,点击前往配置: + + 微信公众平台接口测试帐号 + + 。 +
+
+ 需要新增测试模板,模板标题推荐填写为「消息推送」,模板内容必须填写为 + {' {{'}text.DATA{'}}'}。 +
+ + + + + + + + + + ); + case 'corp_app': + return ( + <> + + 通过企业微信应用号进行推送,点击前往配置: + + 企业微信应用管理 + + 。 +
+
+ 注意,企业微信要求配置可信 IP,步骤:应用管理 -> 自建 -> 创建应用 + -> 应用设置页面下拉中找到「企业可信 IP」,点击配置 -> 设置可信域名 + -> 在「可调用 + JS-SDK、跳转小程序的可信域名」下面填写一个域名,然后点击「申请校验域名」,根据提示完成校验 + -> 之后填写服务器 IP 地址(此 IP + 地址是消息推送服务所部署在的服务器的 IP + 地址,未必是上面校验域名中记录的 IP 地址)。 +
+ + + + + + + + + + + ); + case 'corp': + return ( + <> + + 通过企业微信群机器人进行推送,配置流程:选择一个群聊 -> 设置 -> + 群机器人 -> 添加 -> 新建 -> 输入名字,点击添加 -> 点击复制 Webhook + 地址 + + + + + + ); + case 'lark': + return ( + <> + + 通过飞书群机器人进行推送,飞书桌面客户端的配置流程:选择一个群聊 + -> 设置 -> 群机器人 -> 添加机器人 -> 自定义机器人 -> 添加( + 注意选中「签名校验」)。具体参见: + + 飞书开放文档 + + + + + + + + ); + case 'ding': + return ( + <> + + 通过钉钉群机器人进行推送,钉钉桌面客户端的配置流程:选择一个群聊 + -> 群设置 -> 智能群助手 -> 添加机器人(点击右侧齿轮图标) -> + 自定义 -> 添加( + 注意选中「加密」)。具体参见: + + 钉钉开放文档 + + + + + + + + ); + case 'bark': + return ( + <> + + 通过 Bark 进行推送,下载 Bark 后按提示注册设备,之后会看到一个 + URL,例如 https://api.day.app/wrsVSDRANDOM/Body Text + ,其中 wrsVSDRANDOM 就是你的推送 key。 + + + + + + + ); + case 'client': + return ( + <> + + 通过 WebSocket + 客户端进行推送,可以使用官方客户端实现,或者根据协议自行实现。官方客户端 + + 详见此处 + + 。 + + + + + + ); + case 'telegram': + return ( + <> + + 通过 Telegram 机器人进行消息推送。首先向 + + {' '} + Bot Father{' '} + + 申请创建一个新的机器人,之后在下方输入获取到的令牌,然后点击你的机器人,随便发送一条消息,之后点击下方的「获取会话 + ID」按钮,系统将自动为你填写会话 + ID,最后点击保存按钮保存设置即可。 + + + + + + + + ); + case 'discord': + return ( + <> + + 通过 Discord 群机器人进行推送,配置流程:选择一个 channel -> 设置 + -> 整合 -> 创建 Webhook -> 点击复制 Webhook URL + + + + + + ); + case 'none': + return ( + <> + + 仅保存消息,不做推送,可以在 Web + 端查看,需要用户具有消息持久化的权限。 + + + ); + default: + return ( + <> + 未知通道类型! + + ); + } + }; + + return ( + <> + +
{isEditing ? '更新通道配置' : '新建消息通道'}
+ + + + + + + + + {renderChannelForm()} + + +
+ + ); +}; + +export default EditChannel; diff --git a/web/src/pages/Channel/index.js b/web/src/pages/Channel/index.js new file mode 100644 index 0000000..8b2fa95 --- /dev/null +++ b/web/src/pages/Channel/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { Header, Segment } from 'semantic-ui-react'; +import ChannelsTable from '../../components/ChannelsTable'; + +const Channel = () => ( + <> + +
我的通道
+ +
+ +); + +export default Channel; diff --git a/web/src/pages/User/AddUser.js b/web/src/pages/User/AddUser.js index 73036ad..1835591 100644 --- a/web/src/pages/User/AddUser.js +++ b/web/src/pages/User/AddUser.js @@ -30,38 +30,38 @@ const AddUser = () => { return ( <> -
创建新用户账户
-
+
创建新用户账户
+