diff --git a/README.md b/README.md index 7cd22ba..9c5f40c 100644 --- a/README.md +++ b/README.md @@ -60,17 +60,18 @@ _✨ 搭建专属于你的消息推送服务,支持多种消息推送方式, + Discord 群机器人, + 群组消息:可以将多个推送通道组合成一个群组,然后向群组发送消息,可以实现一次性推送到多个渠道的功能, + 自定义消息,可以自定义消息请求 URL 和请求体格式,实现与其他服务的对接,支持[众多第三方服务](https://iamazing.cn/page/message-pusher-common-custom-templates)。 -2. 支持在 Web 端编辑 & 管理发送的消息,支持自动刷新。 -3. 支持异步消息。 -4. 多种用户登录注册方式: +2. 支持**自定义 Webhook,反向适配各种调用平台**,你可以接入各种已有的系统,而无需修改其代码。 +3. 支持在 Web 端编辑 & 管理发送的消息,支持自动刷新。 +4. 支持异步消息。 +5. 多种用户登录注册方式: + 邮箱登录注册以及通过邮箱进行密码重置。 + [GitHub 开放授权](https://github.com/settings/applications/new)。 + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 -5. 支持 Markdown。 -6. 支持用户管理。 -7. Cloudflare Turnstile 用户校验。 -8. 支持在线发布公告,设置关于界面以及页脚。 -9. API 兼容其他消息推送服务,例如 [Server 酱](https://sct.ftqq.com/)。 +6. 支持 Markdown。 +7. 支持用户管理。 +8. Cloudflare Turnstile 用户校验。 +9. 支持在线发布公告,设置关于界面以及页脚。 +10. API 兼容其他消息推送服务,例如 [Server 酱](https://sct.ftqq.com/)。 ## 用途 1. [整合进自己的博客系统,每当有人登录时发微信消息提醒](https://github.com/songquanpeng/blog/blob/486d63e96ef7906a6c767653a20ec2d3278e9a4a/routes/user.js#L27)。 diff --git a/controller/webhook.go b/controller/webhook.go index 624b041..3f87167 100644 --- a/controller/webhook.go +++ b/controller/webhook.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" + "io" "message-pusher/common" "message-pusher/model" "net/http" @@ -96,12 +97,14 @@ func AddWebhook(c *gin.Context) { return } cleanWebhook := model.Webhook{ - UserId: c.GetInt("id"), - Name: webhook_.Name, - Status: common.WebhookStatusEnabled, - Link: common.GetUUID(), - CreatedTime: common.GetTimestamp(), - ExtractRule: webhook_.ExtractRule, + UserId: c.GetInt("id"), + Name: webhook_.Name, + Status: common.WebhookStatusEnabled, + Link: common.GetUUID(), + CreatedTime: common.GetTimestamp(), + Channel: webhook_.Channel, + ExtractRule: webhook_.ExtractRule, + ConstructRule: webhook_.ConstructRule, } err = cleanWebhook.Insert() if err != nil { @@ -183,8 +186,7 @@ func UpdateWebhook(c *gin.Context) { } func TriggerWebhook(c *gin.Context) { - var reqText string - err := c.Bind(&reqText) + jsonData, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, @@ -192,6 +194,7 @@ func TriggerWebhook(c *gin.Context) { }) return } + reqText := string(jsonData) link := c.Param("link") webhook, err := model.GetWebhookByLink(link) if err != nil { diff --git a/model/webhook.go b/model/webhook.go index 33d5de5..6231a4e 100644 --- a/model/webhook.go +++ b/model/webhook.go @@ -48,7 +48,7 @@ func GetWebhooksByUserId(userId int, startIdx int, num int) (webhooks []*Webhook } func SearchWebhooks(userId int, keyword string) (webhooks []*Webhook, err error) { - err = DB.Where("user_id = ?", userId).Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&webhooks).Error + err = DB.Where("user_id = ?", userId).Where("id = ? or link = ? or name LIKE ?", keyword, keyword, keyword+"%").Find(&webhooks).Error return webhooks, err } @@ -76,10 +76,10 @@ func (webhook *Webhook) UpdateStatus(status int) error { return err } -// Update Make sure your token's fields is completed, because this will update non-zero values +// Update Make sure your token's fields is completed, because this will update zero values func (webhook *Webhook) Update() error { var err error - err = DB.Model(webhook).Select("name", "extract_rule", "construct_rule", "channel").Updates(webhook).Error + err = DB.Model(webhook).Select("status", "name", "extract_rule", "construct_rule", "channel").Updates(webhook).Error return err } diff --git a/web/src/App.js b/web/src/App.js index 1f32b2c..1711133 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -19,6 +19,8 @@ import Message from './pages/Message'; import Channel from './pages/Channel'; import EditChannel from './pages/Channel/EditChannel'; import EditMessage from './pages/Message/EditMessage'; +import Webhook from './pages/Webhook'; +import EditWebhook from './pages/Webhook/EditWebhook'; const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); @@ -134,6 +136,30 @@ function App() { } /> + + + + } + /> + }> + + + } + /> + }> + + + } + /> { + const [webhooks, setWebhooks] = 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 loadWebhooks = async (startIdx) => { + const res = await API.get(`/api/webhook/?p=${startIdx}`); + const { success, message, data } = res.data; + if (success) { + if (startIdx === 0) { + setWebhooks(data); + } else { + let newWebhooks = webhooks; + newWebhooks.push(...data); + setWebhooks(newWebhooks); + } + } else { + showError(message); + } + setLoading(false); + }; + + const onPaginationChange = (e, { activePage }) => { + (async () => { + if (activePage === Math.ceil(webhooks.length / ITEMS_PER_PAGE) + 1) { + // In this case we have to load more data and then append them. + await loadWebhooks(activePage - 1); + } + setActivePage(activePage); + })(); + }; + + useEffect(() => { + loadWebhooks(0) + .then() + .catch((reason) => { + showError(reason); + }); + loadUser() + .then() + .catch((reason) => { + showError(reason); + }); + }, []); + + const manageWebhook = async (id, action, idx) => { + let data = { id }; + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/webhook/${id}/`); + break; + case 'enable': + data.status = 1; + res = await API.put('/api/webhook/?status_only=true', data); + break; + case 'disable': + data.status = 2; + res = await API.put('/api/webhook/?status_only=true', data); + break; + } + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let webhook = res.data.data; + let newWebhooks = [...webhooks]; + let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; + if (action === 'delete') { + newWebhooks[realIdx].deleted = true; + } else { + newWebhooks[realIdx].status = webhook.status; + } + setWebhooks(newWebhooks); + } else { + showError(message); + } + }; + + const renderStatus = (status) => { + switch (status) { + case 1: + return ; + case 2: + return ( + + ); + default: + return ( + + ); + } + }; + + const searchWebhooks = async () => { + if (searchKeyword === '') { + // if keyword is blank, load files instead. + await loadWebhooks(0); + setActivePage(1); + return; + } + setSearching(true); + const res = await API.get(`/api/webhook/search?keyword=${searchKeyword}`); + const { success, message, data } = res.data; + if (success) { + setWebhooks(data); + setActivePage(1); + } else { + showError(message); + } + setSearching(false); + }; + + const handleKeywordChange = async (e, { value }) => { + setSearchKeyword(value.trim()); + }; + + const sortWebhook = (key) => { + if (webhooks.length === 0) return; + setLoading(true); + let sortedWebhooks = [...webhooks]; + sortedWebhooks.sort((a, b) => { + return ('' + a[key]).localeCompare(b[key]); + }); + if (sortedWebhooks[0].id === webhooks[0].id) { + sortedWebhooks.reverse(); + } + setWebhooks(sortedWebhooks); + 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); + }; + + return ( + <> +
+ + + + + + + { + sortWebhook('id'); + }} + > + ID + + { + sortWebhook('name'); + }} + > + 名称 + + { + sortWebhook('status'); + }} + > + 状态 + + { + sortWebhook('created_time'); + }} + > + 创建时间 + + 操作 + + + + + {webhooks + .slice( + (activePage - 1) * ITEMS_PER_PAGE, + activePage * ITEMS_PER_PAGE + ) + .map((webhook, idx) => { + if (webhook.deleted) return <>; + return ( + + {webhook.id} + {webhook.name} + {renderStatus(webhook.status)} + + {renderTimestamp(webhook.created_time)} + + +
+ + + + +
+
+
+ ); + })} +
+ + + + + + + + + +
+ + ); +}; + +export default WebhooksTable; diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 39aa3b5..be5decc 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -132,17 +132,7 @@ export function timestamp2string(timestamp) { second = '0' + second; } return ( - year + - '-' + - month + - '-' + - day + - ' ' + - hour + - ':' + - minute + - ':' + - second + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second ); } @@ -156,19 +146,29 @@ export function downloadTextAsFile(text, filename) { } export async function testChannel(username, token, channel) { - let res = await API.post( - `/push/${username}/`, { - token, - channel, - title: '消息推送服务', - description: channel === "" ? '消息推送通道测试成功' : `消息推送通道 ${channel} 测试成功`, - content: '欢迎使用消息推送服务,这是一条测试消息。' - } - ); + let res = await API.post(`/push/${username}/`, { + token, + channel, + title: '消息推送服务', + description: + channel === '' + ? '消息推送通道测试成功' + : `消息推送通道 ${channel} 测试成功`, + content: '欢迎使用消息推送服务,这是一条测试消息。', + }); const { success, message } = res.data; if (success) { showSuccess('测试消息已发送'); } else { showError(message); } -} \ No newline at end of file +} + +export const verifyJSON = (str) => { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; +}; diff --git a/web/src/pages/Webhook/EditWebhook.js b/web/src/pages/Webhook/EditWebhook.js new file mode 100644 index 0000000..f7ea6db --- /dev/null +++ b/web/src/pages/Webhook/EditWebhook.js @@ -0,0 +1,160 @@ +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, verifyJSON } from '../../helpers'; +import { loadUserChannels } from '../../helpers/loader'; + +const EditWebhook = () => { + const params = useParams(); + const webhookId = params.id; + const isEditing = webhookId !== undefined; + const [loading, setLoading] = useState(isEditing); + const originInputs = { + name: '', + extract_rule: `{ + "title": "attr1", + "description": "attr2.sub_attr", + "content": "attr3", + "url": "attr4" +}`, + construct_rule: + '{\n' + + ' "title": "$title",\n' + + ' "description": "描述信息:$description",\n' + + ' "content": "内容:$content",\n' + + ' "url": "https://example.com/$titl}"\n' + + '}', + channel: '', + }; + + const [inputs, setInputs] = useState(originInputs); + const { name, extract_rule, construct_rule, channel } = inputs; + let [channels, setChannels] = useState([]); + + const handleInputChange = (e, { name, value }) => { + setInputs((inputs) => ({ ...inputs, [name]: value })); + }; + + const loadWebhook = async () => { + let res = await API.get(`/api/webhook/${webhookId}`); + const { success, message, data } = res.data; + if (success) { + setInputs(data); + } else { + showError(message); + } + setLoading(false); + }; + + useEffect(() => { + const loader = async () => { + if (isEditing) { + loadWebhook().then(); + } + let channels = await loadUserChannels(); + if (channels) { + setChannels(channels); + } + }; + loader().then(); + }, []); + + const submit = async () => { + if (!name) return; + if (!verifyJSON(extract_rule)) { + showError('提取规则不是合法的 JSON 格式!'); + return; + } + if (!verifyJSON(construct_rule)) { + showError('构造规则不是合法的 JSON 格式!'); + return; + } + let res = undefined; + let localInputs = { ...inputs }; + if (isEditing) { + res = await API.put(`/api/webhook/`, { + ...localInputs, + id: parseInt(webhookId), + }); + } else { + res = await API.post(`/api/webhook`, localInputs); + } + const { success, message } = res.data; + if (success) { + if (isEditing) { + showSuccess('接口信息更新成功!'); + } else { + showSuccess('接口创建成功!'); + setInputs(originInputs); + } + } else { + showError(message); + } + }; + + return ( + <> + +
{isEditing ? '更新接口配置' : '新建消息接口'}
+
+ + + + + + + + 如果你不知道如何写提取规则和构建规则,请看 + + 此教程 + + 。 + + + + + + + + +
+
+ + ); +}; + +export default EditWebhook; diff --git a/web/src/pages/Webhook/index.js b/web/src/pages/Webhook/index.js new file mode 100644 index 0000000..b42e479 --- /dev/null +++ b/web/src/pages/Webhook/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { Header, Segment } from 'semantic-ui-react'; +import WebhooksTable from '../../components/WebhooksTable'; + +const Webhook = () => ( + <> + +
我的接口
+ +
+ +); + +export default Webhook;