mirror of
				https://github.com/songquanpeng/message-pusher.git
				synced 2025-10-31 19:43:04 +08:00 
			
		
		
		
	feat: channel WebSocket client is ready
This commit is contained in:
		| @@ -50,7 +50,7 @@ _✨ 搭建专属于你的消息推送服务,支持多种消息推送方式, | |||||||
|    + 飞书群机器人, |    + 飞书群机器人, | ||||||
|    + 钉钉群机器人, |    + 钉钉群机器人, | ||||||
|    + Bark App, |    + Bark App, | ||||||
|    + [桌面客户端](https://github.com/songquanpeng/personal-assistant)(WIP) |    + WebSocket 客户端([官方客户端](https://github.com/songquanpeng/personal-assistant),[接入文档](./docs/API.md#WebSocket%20客户端)), | ||||||
| 2. 多种用户登录注册方式: | 2. 多种用户登录注册方式: | ||||||
|    + 邮箱登录注册以及通过邮箱进行密码重置。 |    + 邮箱登录注册以及通过邮箱进行密码重置。 | ||||||
|    + [GitHub 开放授权](https://github.com/settings/applications/new)。 |    + [GitHub 开放授权](https://github.com/settings/applications/new)。 | ||||||
|   | |||||||
| @@ -6,62 +6,147 @@ import ( | |||||||
| 	"message-pusher/common" | 	"message-pusher/common" | ||||||
| 	"message-pusher/model" | 	"message-pusher/model" | ||||||
| 	"sync" | 	"sync" | ||||||
|  | 	"time" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var clientConnMap map[int]*websocket.Conn | const ( | ||||||
|  | 	writeWait      = 10 * time.Second | ||||||
|  | 	pongWait       = 60 * time.Second | ||||||
|  | 	pingPeriod     = (pongWait * 9) / 10 | ||||||
|  | 	maxMessageSize = 512 | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type webSocketClient struct { | ||||||
|  | 	userId    int | ||||||
|  | 	conn      *websocket.Conn | ||||||
|  | 	message   chan *Message | ||||||
|  | 	pong      chan bool | ||||||
|  | 	stop      chan bool | ||||||
|  | 	timestamp int64 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *webSocketClient) handleDataReading() { | ||||||
|  | 	c.conn.SetReadLimit(maxMessageSize) | ||||||
|  | 	_ = c.conn.SetReadDeadline(time.Now().Add(pongWait)) | ||||||
|  | 	c.conn.SetPongHandler(func(string) error { | ||||||
|  | 		return c.conn.SetReadDeadline(time.Now().Add(pongWait)) | ||||||
|  | 	}) | ||||||
|  | 	for { | ||||||
|  | 		messageType, _, err := c.conn.ReadMessage() | ||||||
|  | 		if err != nil { | ||||||
|  | 			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) { | ||||||
|  | 				common.SysError("error read WebSocket client: " + err.Error()) | ||||||
|  | 			} | ||||||
|  | 			c.close() | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 		switch messageType { | ||||||
|  | 		case websocket.PingMessage: | ||||||
|  | 			c.pong <- true | ||||||
|  | 		case websocket.CloseMessage: | ||||||
|  | 			c.close() | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *webSocketClient) handleDataWriting() { | ||||||
|  | 	pingTicker := time.NewTicker(pingPeriod) | ||||||
|  | 	defer func() { | ||||||
|  | 		pingTicker.Stop() | ||||||
|  | 		clientConnMapMutex.Lock() | ||||||
|  | 		client, ok := clientMap[c.userId] | ||||||
|  | 		// otherwise we may delete the new added client! | ||||||
|  | 		if ok && client.timestamp == c.timestamp { | ||||||
|  | 			delete(clientMap, c.userId) | ||||||
|  | 		} | ||||||
|  | 		clientConnMapMutex.Unlock() | ||||||
|  | 		err := c.conn.Close() | ||||||
|  | 		if err != nil { | ||||||
|  | 			common.SysError("error close WebSocket client: " + err.Error()) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | 	for { | ||||||
|  | 		select { | ||||||
|  | 		case message := <-c.message: | ||||||
|  | 			_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | ||||||
|  | 			err := c.conn.WriteJSON(message) | ||||||
|  | 			if err != nil { | ||||||
|  | 				common.SysError("error write data to WebSocket client: " + err.Error()) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 		case <-c.pong: | ||||||
|  | 			err := c.conn.WriteMessage(websocket.PongMessage, nil) | ||||||
|  | 			if err != nil { | ||||||
|  | 				common.SysError("error send pong to WebSocket client: " + err.Error()) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 		case <-pingTicker.C: | ||||||
|  | 			_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | ||||||
|  | 			err := c.conn.WriteMessage(websocket.PingMessage, nil) | ||||||
|  | 			if err != nil { | ||||||
|  | 				common.SysError("error write data to WebSocket client: " + err.Error()) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 		case <-c.stop: | ||||||
|  | 			err := c.conn.WriteMessage(websocket.CloseMessage, nil) | ||||||
|  | 			if err != nil { | ||||||
|  | 				common.SysError("error write data to WebSocket client: " + err.Error()) | ||||||
|  | 			} | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *webSocketClient) sendMessage(message *Message) { | ||||||
|  | 	c.message <- message | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *webSocketClient) close() { | ||||||
|  | 	// should only be called once | ||||||
|  | 	c.stop <- true | ||||||
|  | 	// the defer function in handleDataWriting will do the cleanup | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var clientMap map[int]*webSocketClient | ||||||
| var clientConnMapMutex sync.Mutex | var clientConnMapMutex sync.Mutex | ||||||
|  |  | ||||||
| func init() { | func init() { | ||||||
| 	clientConnMapMutex.Lock() | 	clientConnMapMutex.Lock() | ||||||
| 	clientConnMap = make(map[int]*websocket.Conn) | 	clientMap = make(map[int]*webSocketClient) | ||||||
| 	clientConnMapMutex.Unlock() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func SendMessageWithConn(message *Message, conn *websocket.Conn) error { |  | ||||||
| 	return conn.WriteJSON(message) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func LogoutClient(userId int) { |  | ||||||
| 	clientConnMapMutex.Lock() |  | ||||||
| 	delete(clientConnMap, userId) |  | ||||||
| 	clientConnMapMutex.Unlock() | 	clientConnMapMutex.Unlock() | ||||||
| } | } | ||||||
|  |  | ||||||
| func RegisterClient(userId int, conn *websocket.Conn) { | func RegisterClient(userId int, conn *websocket.Conn) { | ||||||
| 	clientConnMapMutex.Lock() | 	clientConnMapMutex.Lock() | ||||||
| 	oldConn, existed := clientConnMap[userId] | 	oldClient, existed := clientMap[userId] | ||||||
| 	clientConnMapMutex.Unlock() | 	clientConnMapMutex.Unlock() | ||||||
| 	if existed { | 	if existed { | ||||||
| 		byeMessage := &Message{ | 		byeMessage := &Message{ | ||||||
| 			Title:       common.SystemName, | 			Title:       common.SystemName, | ||||||
| 			Description: "其他客户端已连接服务器,本客户端已被挤下线!", | 			Description: "其他客户端已连接服务器,本客户端已被挤下线!", | ||||||
| 		} | 		} | ||||||
| 		err := SendMessageWithConn(byeMessage, oldConn) | 		oldClient.sendMessage(byeMessage) | ||||||
| 		if err != nil { | 		oldClient.close() | ||||||
| 			common.SysError("error send message to client: " + err.Error()) |  | ||||||
| 		} |  | ||||||
| 		err = oldConn.Close() |  | ||||||
| 		if err != nil { |  | ||||||
| 			common.SysError("error close WebSocket connection: " + err.Error()) |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 	helloMessage := &Message{ | 	helloMessage := &Message{ | ||||||
| 		Title:       common.SystemName, | 		Title:       common.SystemName, | ||||||
| 		Description: "客户端连接成功!", | 		Description: "客户端连接成功!", | ||||||
| 	} | 	} | ||||||
| 	err := SendMessageWithConn(helloMessage, conn) | 	newClient := &webSocketClient{ | ||||||
| 	if err != nil { | 		userId:    userId, | ||||||
| 		common.SysError("error send message to client: " + err.Error()) | 		conn:      conn, | ||||||
| 		return | 		message:   make(chan *Message), | ||||||
| 	} else { | 		pong:      make(chan bool), | ||||||
| 		clientConnMapMutex.Lock() | 		stop:      make(chan bool), | ||||||
| 		clientConnMap[userId] = conn | 		timestamp: time.Now().UnixMilli(), | ||||||
| 		clientConnMapMutex.Unlock() |  | ||||||
| 		conn.SetCloseHandler(func(code int, text string) error { |  | ||||||
| 			LogoutClient(userId) |  | ||||||
| 			return nil |  | ||||||
| 		}) |  | ||||||
| 	} | 	} | ||||||
|  | 	go newClient.handleDataWriting() | ||||||
|  | 	go newClient.handleDataReading() | ||||||
|  | 	defer newClient.sendMessage(helloMessage) | ||||||
|  | 	clientConnMapMutex.Lock() | ||||||
|  | 	clientMap[userId] = newClient | ||||||
|  | 	clientConnMapMutex.Unlock() | ||||||
| } | } | ||||||
|  |  | ||||||
| func SendClientMessage(message *Message, user *model.User) error { | func SendClientMessage(message *Message, user *model.User) error { | ||||||
| @@ -69,14 +154,11 @@ func SendClientMessage(message *Message, user *model.User) error { | |||||||
| 		return errors.New("未配置 WebSocket 客户端消息推送方式") | 		return errors.New("未配置 WebSocket 客户端消息推送方式") | ||||||
| 	} | 	} | ||||||
| 	clientConnMapMutex.Lock() | 	clientConnMapMutex.Lock() | ||||||
| 	conn, existed := clientConnMap[user.Id] | 	client, existed := clientMap[user.Id] | ||||||
| 	clientConnMapMutex.Unlock() | 	clientConnMapMutex.Unlock() | ||||||
| 	if !existed { | 	if !existed { | ||||||
| 		return errors.New("客户端未连接") | 		return errors.New("客户端未连接") | ||||||
| 	} | 	} | ||||||
| 	err := SendMessageWithConn(message, conn) | 	client.sendMessage(message) | ||||||
| 	if err != nil { | 	return nil | ||||||
| 		LogoutClient(user.Id) |  | ||||||
| 	} |  | ||||||
| 	return err |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,7 +8,13 @@ import ( | |||||||
| 	"net/http" | 	"net/http" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var upgrader = websocket.Upgrader{} // use default options | var upgrader = websocket.Upgrader{ | ||||||
|  | 	ReadBufferSize:  1024, | ||||||
|  | 	WriteBufferSize: 1024, | ||||||
|  | 	CheckOrigin: func(r *http.Request) bool { | ||||||
|  | 		return true | ||||||
|  | 	}, | ||||||
|  | } | ||||||
|  |  | ||||||
| func RegisterClient(c *gin.Context) { | func RegisterClient(c *gin.Context) { | ||||||
| 	secret := c.Query("secret") | 	secret := c.Query("secret") | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								docs/API.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								docs/API.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | # API 文档 | ||||||
|  |  | ||||||
|  | ## WebSocket 客户端 | ||||||
|  | 你可以使用 WebSocket 客户端连接服务器,具体的客户端的类型可以是桌面应用,手机应用或 Web 应用等,只要遵循下述协议即可。 | ||||||
|  |  | ||||||
|  | 目前同一时间一个用户只能有一个客户端连接到服务器,之前已连接的客户端将被断开连接。 | ||||||
|  |  | ||||||
|  | ### 连接协议 | ||||||
|  | 1. API 端点为:`ws://<domain>:<port>/api/register_client/<username>?secret=<secret>` | ||||||
|  | 2. 如果启用了 HTTPS,则需要将 `ws` 替换为 `wss`。 | ||||||
|  | 3. 上述 `secret` 为用户在后台设置的 `服务器连接密钥`,而非 `推送 token`。 | ||||||
|  |  | ||||||
|  | ### 接收消息 | ||||||
|  | 1. 消息编码格式为 JSON。 | ||||||
|  | 2. 具体内容: | ||||||
|  |    ```json | ||||||
|  |    { | ||||||
|  |     "title": "标题", | ||||||
|  |     "description": "描述", | ||||||
|  |     "content": "内容", | ||||||
|  |     "html_content": "转换为 HTML 后的内容", | ||||||
|  |     "url": "链接" | ||||||
|  |    } | ||||||
|  |    ``` | ||||||
|  |    可能还有多余字段,忽略即可。     | ||||||
|  |  | ||||||
|  | ### 连接保活 | ||||||
|  | 1. 每 `56s` 服务器将发送 `ping` 报文,客户端需要在 `60s` 回复 `pong` 报文,否则服务端将不再维护该连接。 | ||||||
|  | 2. 服务端会主动回复客户端发来的 `ping` 报文。 | ||||||
|  |  | ||||||
|  | ### 实现列表 | ||||||
|  | 当前可用的 WebSocket 客户端实现有: | ||||||
|  | 1. 官方 WebSocket 桌面客户端实现:https://github.com/songquanpeng/personal-assistant | ||||||
|  | 2. 待补充 | ||||||
|  |  | ||||||
|  | 欢迎在此提交你的客户端实现。 | ||||||
		Reference in New Issue
	
	Block a user
	 JustSong
					JustSong