mirror of
https://github.com/kevin-DL/services.git
synced 2026-01-20 06:25:07 +00:00
add the chat service (#381)
This commit is contained in:
27
chat/Makefile
Normal file
27
chat/Makefile
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
GOPATH:=$(shell go env GOPATH)
|
||||
.PHONY: init
|
||||
init:
|
||||
go get -u github.com/golang/protobuf/proto
|
||||
go get -u github.com/golang/protobuf/protoc-gen-go
|
||||
go get github.com/micro/micro/v3/cmd/protoc-gen-micro
|
||||
|
||||
.PHONY: api
|
||||
api:
|
||||
protoc --openapi_out=. --proto_path=. proto/chat.proto
|
||||
|
||||
.PHONY: proto
|
||||
proto:
|
||||
protoc --proto_path=. --micro_out=. --go_out=:. proto/chat.proto
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
go build -o chat *.go
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -v ./... -cover
|
||||
|
||||
.PHONY: docker
|
||||
docker:
|
||||
docker build . -t chat:latest
|
||||
7
chat/README.md
Normal file
7
chat/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Real time messaging
|
||||
|
||||
|
||||
# Chat Service
|
||||
|
||||
The Chat service is a programmable instant messaging API service which can be used in any application to immediately create conversations.
|
||||
|
||||
174
chat/examples.json
Normal file
174
chat/examples.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"new": [{
|
||||
"title": "Create a new chat",
|
||||
"description": "Create a new chat by name",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"name": "general",
|
||||
"description": "The general chat room"
|
||||
},
|
||||
"response": {
|
||||
"room": {
|
||||
"id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"name": "general",
|
||||
"description": "The general chat room",
|
||||
"created_at": "2022-02-17T16:12:43.942557998Z",
|
||||
"user_ids": [],
|
||||
"private": false
|
||||
}
|
||||
}
|
||||
}],
|
||||
"list": [{
|
||||
"title": "List chat rooms",
|
||||
"description": "List all the chat rooms",
|
||||
"run_check": false,
|
||||
"request": {},
|
||||
"response": {
|
||||
"rooms": [{
|
||||
"id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"name": "general",
|
||||
"description": "The general chat room",
|
||||
"created_at": "2022-02-17T16:12:43.942557998Z",
|
||||
"user_ids": [],
|
||||
"private": false
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"delete": [{
|
||||
"title": "Delete a chat",
|
||||
"description": "Delete a chat room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910"
|
||||
},
|
||||
"response": {
|
||||
"room": {
|
||||
"id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"name": "general",
|
||||
"description": "The general chat room",
|
||||
"created_at": "2022-02-17T16:12:43.942557998Z",
|
||||
"user_ids": [],
|
||||
"private": false
|
||||
}
|
||||
}
|
||||
}],
|
||||
"invite": [{
|
||||
"title": "Invite a user",
|
||||
"description": "Invite a user to a chat room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"response": {
|
||||
"room": {
|
||||
"id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"name": "general",
|
||||
"description": "The general chat room",
|
||||
"created_at": "2022-02-17T16:12:43.942557998Z",
|
||||
"user_ids": ["user-1"],
|
||||
"private": false
|
||||
}
|
||||
}
|
||||
}],
|
||||
"send": [{
|
||||
"title": "Send a message",
|
||||
"description": "Send a message to a room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1",
|
||||
"client": "web",
|
||||
"subject": "Random",
|
||||
"text": "Hey whats up?"
|
||||
},
|
||||
"response": {
|
||||
"message": {
|
||||
"id": "d44c6dc0-89d7-4a36-b528-cfd6c728ccef",
|
||||
"client": "web",
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1",
|
||||
"sent_at": "2022-02-17T16:18:35.683008885Z",
|
||||
"subject": "Random",
|
||||
"text": "Hey whats up?"
|
||||
}
|
||||
}
|
||||
}],
|
||||
"history": [{
|
||||
"title": "Get chat history",
|
||||
"description": "Get chat history for a room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910"
|
||||
},
|
||||
"response": {
|
||||
"messages": [{
|
||||
"id": "d44c6dc0-89d7-4a36-b528-cfd6c728ccef",
|
||||
"client": "web",
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1",
|
||||
"sent_at": "2022-02-17T16:18:35.683008885Z",
|
||||
"subject": "Random",
|
||||
"text": "Hey whats up?"
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"join": [{
|
||||
"title": "Join a room",
|
||||
"description": "Join a room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-2"
|
||||
},
|
||||
"response": {
|
||||
"messages": [{
|
||||
"id": "d44c6dc0-89d7-4a36-b528-cfd6c728ccef",
|
||||
"client": "web",
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1",
|
||||
"sent_at": "2022-02-17T16:18:35.683008885Z",
|
||||
"subject": "Random",
|
||||
"text": "Hey whats up?"
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"kick": [{
|
||||
"title": "Kick a user from a room",
|
||||
"description": "Kick a user from a chat room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"response": {
|
||||
"room": {
|
||||
"id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"name": "general",
|
||||
"description": "The general chat room",
|
||||
"created_at": "2022-02-17T16:12:43.942557998Z",
|
||||
"user_ids": [],
|
||||
"private": false
|
||||
}
|
||||
}
|
||||
}],
|
||||
"leave": [{
|
||||
"title": "Leave a room",
|
||||
"description": "Leave a chat room",
|
||||
"run_check": false,
|
||||
"request": {
|
||||
"room_id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"response": {
|
||||
"room": {
|
||||
"id": "d8057208-f81a-4e14-ad7f-c29daa2bb910",
|
||||
"name": "general",
|
||||
"description": "The general chat room",
|
||||
"created_at": "2022-02-17T16:12:43.942557998Z",
|
||||
"user_ids": [],
|
||||
"private": false
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
540
chat/handler/handler.go
Normal file
540
chat/handler/handler.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/micro/micro/v3/service/errors"
|
||||
"github.com/micro/micro/v3/service/events"
|
||||
"github.com/micro/micro/v3/service/logger"
|
||||
"github.com/micro/micro/v3/service/store"
|
||||
pb "github.com/micro/services/chat/proto"
|
||||
"github.com/micro/services/pkg/tenant"
|
||||
)
|
||||
|
||||
const (
|
||||
chatStoreKeyPrefix = "chats/"
|
||||
chatEventKeyPrefix = "chats/"
|
||||
messageStoreKeyPrefix = "messages/"
|
||||
)
|
||||
|
||||
type Chat struct{}
|
||||
|
||||
func (c *Chat) New(ctx context.Context, req *pb.NewRequest, rsp *pb.NewResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// generate a unique id for the chat
|
||||
roomId := uuid.New().String()
|
||||
|
||||
// create a new room
|
||||
room := &pb.Room{
|
||||
Id: roomId,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
UserIds: req.UserIds,
|
||||
Private: req.Private,
|
||||
CreatedAt: time.Now().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
// key to lookup the chat in the store using, e.g. "chat/usera-userb-userc"
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, roomId)
|
||||
|
||||
// create a new record for the room
|
||||
rec := store.NewRecord(key, room)
|
||||
|
||||
// write a record for the new room
|
||||
if err := store.Write(rec); err != nil {
|
||||
logger.Errorf("Error writing to the store. Key: %v. Error: %v", key, err)
|
||||
return errors.InternalServerError("chat.new", "error creating chat room")
|
||||
}
|
||||
|
||||
// return the room
|
||||
rsp.Room = room
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Chat) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.delete", "missing room id")
|
||||
}
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat from the store to ensure it's valid
|
||||
recs, err := store.Read(key, store.ReadLimit(1))
|
||||
if err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.delete", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.delete", "error reading chat room")
|
||||
}
|
||||
|
||||
room := new(pb.Room)
|
||||
err = recs[0].Decode(room)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.delete", "error reading chat room")
|
||||
}
|
||||
// set response
|
||||
rsp.Room = room
|
||||
|
||||
// delete the room
|
||||
if err := store.Delete(key); err != nil {
|
||||
return errors.InternalServerError("chat.delete", "error deleting chat room")
|
||||
}
|
||||
|
||||
// get all messages
|
||||
// TODO: paginate the list
|
||||
key = path.Join(messageStoreKeyPrefix, tenantId, req.RoomId)
|
||||
srecs, err := store.List(store.ListPrefix(key))
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.delete", "failed to list messages")
|
||||
}
|
||||
|
||||
// delete all the messages
|
||||
for _, rec := range srecs {
|
||||
if err := store.Delete(rec); err != nil {
|
||||
return errors.InternalServerError("chat.delete", "failed to list messages")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: notify users of the event that the room is deleted
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Chat) List(ctx context.Context, req *pb.ListRequest, rsp *pb.ListResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId) + "/"
|
||||
|
||||
// read all the rooms from the store for the user
|
||||
recs, err := store.Read(key, store.ReadPrefix())
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.list", "error listing chat rooms")
|
||||
}
|
||||
|
||||
// list all the rooms
|
||||
for _, rec := range recs {
|
||||
room := new(pb.Room)
|
||||
err := rec.Decode(room)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(req.UserId) == 0 {
|
||||
rsp.Rooms = append(rsp.Rooms, room)
|
||||
continue
|
||||
}
|
||||
|
||||
// check if there's a user id match
|
||||
for _, user := range room.UserIds {
|
||||
if user == req.UserId {
|
||||
rsp.Rooms = append(rsp.Rooms, room)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// History returns the historical messages in a chat
|
||||
func (c *Chat) History(ctx context.Context, req *pb.HistoryRequest, rsp *pb.HistoryResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.history", "missing room id")
|
||||
}
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat from the store to ensure it's valid
|
||||
if _, err := store.Read(key); err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.history", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.history", "error reading chat room")
|
||||
}
|
||||
|
||||
// lookup the messages
|
||||
key = path.Join(messageStoreKeyPrefix, tenantId, req.RoomId)
|
||||
recs, err := store.Read(key+"/", store.ReadPrefix())
|
||||
if err != nil {
|
||||
logger.Errorf("Error reading messages the store. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.history", "failed to read messages")
|
||||
}
|
||||
|
||||
for _, rec := range recs {
|
||||
msg := new(pb.Message)
|
||||
err := rec.Decode(msg)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.history", "failed to decode message")
|
||||
}
|
||||
rsp.Messages = append(rsp.Messages, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Chat) Invite(ctx context.Context, req *pb.InviteRequest, rsp *pb.InviteResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.invite", "missing room id")
|
||||
}
|
||||
|
||||
if len(req.UserId) == 0 {
|
||||
return errors.BadRequest("chat.invite", "missing user id")
|
||||
}
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat from the store to ensure it's valid
|
||||
recs, err := store.Read(key)
|
||||
if err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.invite", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.invite", "error reading chat room")
|
||||
}
|
||||
|
||||
// check the user is in the room
|
||||
room := new(pb.Room)
|
||||
err = recs[0].Decode(room)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.invite", "Error reading room")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// check the user is in the room
|
||||
for _, user := range room.UserIds {
|
||||
if user == req.UserId {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: send join message
|
||||
if !exists {
|
||||
room.UserIds = append(room.UserIds, req.UserId)
|
||||
// write the record
|
||||
rec := store.NewRecord(key, room)
|
||||
if err := store.Write(rec); err != nil {
|
||||
return errors.InternalServerError("chat.invite", "Error adding user to room")
|
||||
}
|
||||
}
|
||||
|
||||
rsp.Room = room
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send a single message to the chat, designed for ease of use via the API / CLI
|
||||
func (c *Chat) Send(ctx context.Context, req *pb.SendRequest, rsp *pb.SendResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.send", "missing room id")
|
||||
}
|
||||
if len(req.UserId) == 0 {
|
||||
return errors.BadRequest("chat.send", "missing user id")
|
||||
}
|
||||
if len(req.Text) == 0 {
|
||||
return errors.BadRequest("chat.send", "missing text")
|
||||
}
|
||||
|
||||
// check the room exists
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat room from the store to ensure it's valid
|
||||
recs, err := store.Read(key, store.ReadLimit(1))
|
||||
if err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.send", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.send", "error reading chat room")
|
||||
}
|
||||
|
||||
// decode the room
|
||||
room := new(pb.Room)
|
||||
err = recs[0].Decode(room)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.send", "error reading chat room")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// check the user is in the room
|
||||
for _, user := range room.UserIds {
|
||||
if user == req.UserId {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return errors.BadRequest("chat.send", "user is not in the room")
|
||||
}
|
||||
|
||||
// construct the message
|
||||
msg := &pb.Message{
|
||||
Id: uuid.New().String(),
|
||||
Client: req.Client,
|
||||
RoomId: req.RoomId,
|
||||
UserId: req.UserId,
|
||||
Subject: req.Subject,
|
||||
Text: req.Text,
|
||||
SentAt: time.Now().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
// default the client id if not provided
|
||||
if len(msg.Client) == 0 {
|
||||
msg.Client = uuid.New().String()
|
||||
}
|
||||
|
||||
// create the message
|
||||
if err := c.createMessage(tenantId, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// return the response
|
||||
rsp.Message = msg
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Chat) Join(ctx context.Context, req *pb.JoinRequest, stream pb.Chat_JoinStream) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.send", "missing room id")
|
||||
}
|
||||
if len(req.UserId) == 0 {
|
||||
return errors.BadRequest("chat.send", "missing user id")
|
||||
}
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat from the store to ensure it's valid
|
||||
recs, err := store.Read(key, store.ReadLimit(1))
|
||||
if err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.join", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.join", "Error reading room")
|
||||
}
|
||||
|
||||
// check the user is in the room
|
||||
room := new(pb.Room)
|
||||
err = recs[0].Decode(room)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.join", "Error reading room")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// check the user is in the room
|
||||
for _, user := range room.UserIds {
|
||||
if user == req.UserId {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: send join message
|
||||
if !exists {
|
||||
room.UserIds = append(room.UserIds, req.UserId)
|
||||
// write the record
|
||||
rec := store.NewRecord(key, room)
|
||||
if err := store.Write(rec); err != nil {
|
||||
return errors.InternalServerError("chat.join", "Error adding user to room")
|
||||
}
|
||||
}
|
||||
|
||||
// create a channel to send errors on, because the subscriber / publisher will run in seperate go-
|
||||
// routines, they need a way of returning errors to the client
|
||||
errChan := make(chan error)
|
||||
|
||||
eventKey := path.Join(chatEventKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// create an event stream to consume messages posted by other users into the chat. we'll use the
|
||||
// user id as a queue to ensure each user recieves the message
|
||||
evStream, err := events.Consume(eventKey, events.WithGroup(req.UserId), events.WithContext(ctx))
|
||||
if err != nil {
|
||||
logger.Errorf("Error streaming events. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.join", "Error joining the room")
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// the context has been cancelled or timed out, stop subscribing to new messages
|
||||
return nil
|
||||
case ev := <-evStream:
|
||||
// recieved a message, unmarshal it into a message struct. if an error occurs log it and
|
||||
// cancel the context
|
||||
var msg pb.Message
|
||||
if err := ev.Unmarshal(&msg); err != nil {
|
||||
logger.Errorf("Error unmarshaling message. Room ID: %v. Error: %v", req.RoomId, err)
|
||||
errChan <- err
|
||||
return nil
|
||||
}
|
||||
|
||||
// ignore any messages published by the current user
|
||||
if msg.UserId == req.UserId {
|
||||
continue
|
||||
}
|
||||
|
||||
// publish the message to the stream
|
||||
if err := stream.Send(&msg); err != nil {
|
||||
logger.Errorf("Error sending message to stream. ChatID: %v. Message ID: %v. Error: %v", msg.RoomId, msg.Id, err)
|
||||
errChan <- err
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Chat) Kick(ctx context.Context, req *pb.KickRequest, rsp *pb.KickResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.kick", "missing room id")
|
||||
}
|
||||
if len(req.UserId) == 0 {
|
||||
return errors.BadRequest("chat.kick", "missing user id")
|
||||
}
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat from the store to ensure it's valid
|
||||
recs, err := store.Read(key, store.ReadLimit(1))
|
||||
if err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.kick", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Chat ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.kick", "Error reading room")
|
||||
}
|
||||
|
||||
// check the user is in the room
|
||||
room := new(pb.Room)
|
||||
err = recs[0].Decode(room)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.kick", "Error reading room")
|
||||
}
|
||||
|
||||
var users []string
|
||||
|
||||
// check the user is in the room
|
||||
for _, user := range room.UserIds {
|
||||
if user == req.UserId {
|
||||
continue
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
room.UserIds = users
|
||||
|
||||
rec := store.NewRecord(key, room)
|
||||
if err := store.Write(rec); err != nil {
|
||||
return errors.InternalServerError("chat.kick", "Error leaveing from room")
|
||||
}
|
||||
|
||||
// TODO: send leave message
|
||||
// TODO: disconnect the actual event consumption
|
||||
rsp.Room = room
|
||||
|
||||
return nil
|
||||
}
|
||||
func (c *Chat) Leave(ctx context.Context, req *pb.LeaveRequest, rsp *pb.LeaveResponse) error {
|
||||
// get the tenant
|
||||
tenantId := tenant.Id(ctx)
|
||||
|
||||
// validate the request
|
||||
if len(req.RoomId) == 0 {
|
||||
return errors.BadRequest("chat.leave", "missing room id")
|
||||
}
|
||||
if len(req.UserId) == 0 {
|
||||
return errors.BadRequest("chat.leave", "missing user id")
|
||||
}
|
||||
|
||||
key := path.Join(chatStoreKeyPrefix, tenantId, req.RoomId)
|
||||
|
||||
// lookup the chat from the store to ensure it's valid
|
||||
recs, err := store.Read(key, store.ReadLimit(1))
|
||||
if err == store.ErrNotFound {
|
||||
return errors.BadRequest("chat.leave", "room not found")
|
||||
} else if err != nil {
|
||||
logger.Errorf("Error reading from the store. Chat ID: %v. Error: %v", req.RoomId, err)
|
||||
return errors.InternalServerError("chat.leave", "Error reading room")
|
||||
}
|
||||
|
||||
// check the user is in the room
|
||||
room := new(pb.Room)
|
||||
err = recs[0].Decode(room)
|
||||
if err != nil {
|
||||
return errors.InternalServerError("chat.leave", "Error reading room")
|
||||
}
|
||||
|
||||
var users []string
|
||||
|
||||
// check the user is in the room
|
||||
for _, user := range room.UserIds {
|
||||
if user == req.UserId {
|
||||
continue
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
room.UserIds = users
|
||||
|
||||
rec := store.NewRecord(key, room)
|
||||
if err := store.Write(rec); err != nil {
|
||||
return errors.InternalServerError("chat.leave", "Error leaveing from room")
|
||||
}
|
||||
|
||||
// TODO: send leave message
|
||||
// TODO: disconnect the actual event consumption
|
||||
rsp.Room = room
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createMessage is a helper function which creates a message in the event stream. It handles the
|
||||
// logic for ensuring client id is unique.
|
||||
func (c *Chat) createMessage(tenantId string, msg *pb.Message) error {
|
||||
storekey := path.Join(messageStoreKeyPrefix, tenantId, msg.RoomId, msg.Id)
|
||||
eventKey := path.Join(chatEventKeyPrefix, tenantId, msg.RoomId)
|
||||
|
||||
// send the message to the event stream
|
||||
if err := events.Publish(eventKey, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create a new record
|
||||
rec := store.NewRecord(storekey, msg)
|
||||
|
||||
// record the messages client id
|
||||
return store.Write(rec)
|
||||
}
|
||||
24
chat/main.go
Normal file
24
chat/main.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/micro/micro/v3/service"
|
||||
"github.com/micro/micro/v3/service/logger"
|
||||
"github.com/micro/services/chat/handler"
|
||||
pb "github.com/micro/services/chat/proto"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create the service
|
||||
srv := service.New(
|
||||
service.Name("chat"),
|
||||
service.Version("latest"),
|
||||
)
|
||||
|
||||
// Register the handler against the server
|
||||
pb.RegisterChatHandler(srv.Server(), new(handler.Chat))
|
||||
|
||||
// Run the service
|
||||
if err := srv.Run(); err != nil {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
}
|
||||
1
chat/micro.mu
Normal file
1
chat/micro.mu
Normal file
@@ -0,0 +1 @@
|
||||
service chat
|
||||
1585
chat/proto/chat.pb.go
Normal file
1585
chat/proto/chat.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
304
chat/proto/chat.pb.micro.go
Normal file
304
chat/proto/chat.pb.micro.go
Normal file
@@ -0,0 +1,304 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: proto/chat.proto
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
proto "github.com/golang/protobuf/proto"
|
||||
math "math"
|
||||
)
|
||||
|
||||
import (
|
||||
context "context"
|
||||
api "github.com/micro/micro/v3/service/api"
|
||||
client "github.com/micro/micro/v3/service/client"
|
||||
server "github.com/micro/micro/v3/service/server"
|
||||
)
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ = proto.Marshal
|
||||
var _ = fmt.Errorf
|
||||
var _ = math.Inf
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the proto package it is being compiled against.
|
||||
// A compilation error at this line likely means your copy of the
|
||||
// proto package needs to be updated.
|
||||
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ api.Endpoint
|
||||
var _ context.Context
|
||||
var _ client.Option
|
||||
var _ server.Option
|
||||
|
||||
// Api Endpoints for Chat service
|
||||
|
||||
func NewChatEndpoints() []*api.Endpoint {
|
||||
return []*api.Endpoint{}
|
||||
}
|
||||
|
||||
// Client API for Chat service
|
||||
|
||||
type ChatService interface {
|
||||
New(ctx context.Context, in *NewRequest, opts ...client.CallOption) (*NewResponse, error)
|
||||
History(ctx context.Context, in *HistoryRequest, opts ...client.CallOption) (*HistoryResponse, error)
|
||||
Send(ctx context.Context, in *SendRequest, opts ...client.CallOption) (*SendResponse, error)
|
||||
List(ctx context.Context, in *ListRequest, opts ...client.CallOption) (*ListResponse, error)
|
||||
Delete(ctx context.Context, in *DeleteRequest, opts ...client.CallOption) (*DeleteResponse, error)
|
||||
Join(ctx context.Context, in *JoinRequest, opts ...client.CallOption) (Chat_JoinService, error)
|
||||
Invite(ctx context.Context, in *InviteRequest, opts ...client.CallOption) (*InviteResponse, error)
|
||||
Leave(ctx context.Context, in *LeaveRequest, opts ...client.CallOption) (*LeaveResponse, error)
|
||||
Kick(ctx context.Context, in *KickRequest, opts ...client.CallOption) (*KickResponse, error)
|
||||
}
|
||||
|
||||
type chatService struct {
|
||||
c client.Client
|
||||
name string
|
||||
}
|
||||
|
||||
func NewChatService(name string, c client.Client) ChatService {
|
||||
return &chatService{
|
||||
c: c,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *chatService) New(ctx context.Context, in *NewRequest, opts ...client.CallOption) (*NewResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.New", in)
|
||||
out := new(NewResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) History(ctx context.Context, in *HistoryRequest, opts ...client.CallOption) (*HistoryResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.History", in)
|
||||
out := new(HistoryResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) Send(ctx context.Context, in *SendRequest, opts ...client.CallOption) (*SendResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.Send", in)
|
||||
out := new(SendResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) List(ctx context.Context, in *ListRequest, opts ...client.CallOption) (*ListResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.List", in)
|
||||
out := new(ListResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) Delete(ctx context.Context, in *DeleteRequest, opts ...client.CallOption) (*DeleteResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.Delete", in)
|
||||
out := new(DeleteResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) Join(ctx context.Context, in *JoinRequest, opts ...client.CallOption) (Chat_JoinService, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.Join", &JoinRequest{})
|
||||
stream, err := c.c.Stream(ctx, req, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := stream.Send(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &chatServiceJoin{stream}, nil
|
||||
}
|
||||
|
||||
type Chat_JoinService interface {
|
||||
Context() context.Context
|
||||
SendMsg(interface{}) error
|
||||
RecvMsg(interface{}) error
|
||||
Close() error
|
||||
Recv() (*Message, error)
|
||||
}
|
||||
|
||||
type chatServiceJoin struct {
|
||||
stream client.Stream
|
||||
}
|
||||
|
||||
func (x *chatServiceJoin) Close() error {
|
||||
return x.stream.Close()
|
||||
}
|
||||
|
||||
func (x *chatServiceJoin) Context() context.Context {
|
||||
return x.stream.Context()
|
||||
}
|
||||
|
||||
func (x *chatServiceJoin) SendMsg(m interface{}) error {
|
||||
return x.stream.Send(m)
|
||||
}
|
||||
|
||||
func (x *chatServiceJoin) RecvMsg(m interface{}) error {
|
||||
return x.stream.Recv(m)
|
||||
}
|
||||
|
||||
func (x *chatServiceJoin) Recv() (*Message, error) {
|
||||
m := new(Message)
|
||||
err := x.stream.Recv(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *chatService) Invite(ctx context.Context, in *InviteRequest, opts ...client.CallOption) (*InviteResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.Invite", in)
|
||||
out := new(InviteResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) Leave(ctx context.Context, in *LeaveRequest, opts ...client.CallOption) (*LeaveResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.Leave", in)
|
||||
out := new(LeaveResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *chatService) Kick(ctx context.Context, in *KickRequest, opts ...client.CallOption) (*KickResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "Chat.Kick", in)
|
||||
out := new(KickResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Server API for Chat service
|
||||
|
||||
type ChatHandler interface {
|
||||
New(context.Context, *NewRequest, *NewResponse) error
|
||||
History(context.Context, *HistoryRequest, *HistoryResponse) error
|
||||
Send(context.Context, *SendRequest, *SendResponse) error
|
||||
List(context.Context, *ListRequest, *ListResponse) error
|
||||
Delete(context.Context, *DeleteRequest, *DeleteResponse) error
|
||||
Join(context.Context, *JoinRequest, Chat_JoinStream) error
|
||||
Invite(context.Context, *InviteRequest, *InviteResponse) error
|
||||
Leave(context.Context, *LeaveRequest, *LeaveResponse) error
|
||||
Kick(context.Context, *KickRequest, *KickResponse) error
|
||||
}
|
||||
|
||||
func RegisterChatHandler(s server.Server, hdlr ChatHandler, opts ...server.HandlerOption) error {
|
||||
type chat interface {
|
||||
New(ctx context.Context, in *NewRequest, out *NewResponse) error
|
||||
History(ctx context.Context, in *HistoryRequest, out *HistoryResponse) error
|
||||
Send(ctx context.Context, in *SendRequest, out *SendResponse) error
|
||||
List(ctx context.Context, in *ListRequest, out *ListResponse) error
|
||||
Delete(ctx context.Context, in *DeleteRequest, out *DeleteResponse) error
|
||||
Join(ctx context.Context, stream server.Stream) error
|
||||
Invite(ctx context.Context, in *InviteRequest, out *InviteResponse) error
|
||||
Leave(ctx context.Context, in *LeaveRequest, out *LeaveResponse) error
|
||||
Kick(ctx context.Context, in *KickRequest, out *KickResponse) error
|
||||
}
|
||||
type Chat struct {
|
||||
chat
|
||||
}
|
||||
h := &chatHandler{hdlr}
|
||||
return s.Handle(s.NewHandler(&Chat{h}, opts...))
|
||||
}
|
||||
|
||||
type chatHandler struct {
|
||||
ChatHandler
|
||||
}
|
||||
|
||||
func (h *chatHandler) New(ctx context.Context, in *NewRequest, out *NewResponse) error {
|
||||
return h.ChatHandler.New(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) History(ctx context.Context, in *HistoryRequest, out *HistoryResponse) error {
|
||||
return h.ChatHandler.History(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) Send(ctx context.Context, in *SendRequest, out *SendResponse) error {
|
||||
return h.ChatHandler.Send(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) List(ctx context.Context, in *ListRequest, out *ListResponse) error {
|
||||
return h.ChatHandler.List(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) Delete(ctx context.Context, in *DeleteRequest, out *DeleteResponse) error {
|
||||
return h.ChatHandler.Delete(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) Join(ctx context.Context, stream server.Stream) error {
|
||||
m := new(JoinRequest)
|
||||
if err := stream.Recv(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.ChatHandler.Join(ctx, m, &chatJoinStream{stream})
|
||||
}
|
||||
|
||||
type Chat_JoinStream interface {
|
||||
Context() context.Context
|
||||
SendMsg(interface{}) error
|
||||
RecvMsg(interface{}) error
|
||||
Close() error
|
||||
Send(*Message) error
|
||||
}
|
||||
|
||||
type chatJoinStream struct {
|
||||
stream server.Stream
|
||||
}
|
||||
|
||||
func (x *chatJoinStream) Close() error {
|
||||
return x.stream.Close()
|
||||
}
|
||||
|
||||
func (x *chatJoinStream) Context() context.Context {
|
||||
return x.stream.Context()
|
||||
}
|
||||
|
||||
func (x *chatJoinStream) SendMsg(m interface{}) error {
|
||||
return x.stream.Send(m)
|
||||
}
|
||||
|
||||
func (x *chatJoinStream) RecvMsg(m interface{}) error {
|
||||
return x.stream.Recv(m)
|
||||
}
|
||||
|
||||
func (x *chatJoinStream) Send(m *Message) error {
|
||||
return x.stream.Send(m)
|
||||
}
|
||||
|
||||
func (h *chatHandler) Invite(ctx context.Context, in *InviteRequest, out *InviteResponse) error {
|
||||
return h.ChatHandler.Invite(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) Leave(ctx context.Context, in *LeaveRequest, out *LeaveResponse) error {
|
||||
return h.ChatHandler.Leave(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *chatHandler) Kick(ctx context.Context, in *KickRequest, out *KickResponse) error {
|
||||
return h.ChatHandler.Kick(ctx, in, out)
|
||||
}
|
||||
163
chat/proto/chat.proto
Normal file
163
chat/proto/chat.proto
Normal file
@@ -0,0 +1,163 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package chat;
|
||||
option go_package = "./proto;chat";
|
||||
|
||||
service Chat {
|
||||
rpc New(NewRequest) returns (NewResponse);
|
||||
rpc History(HistoryRequest) returns (HistoryResponse);
|
||||
rpc Send(SendRequest) returns (SendResponse);
|
||||
rpc List(ListRequest) returns (ListResponse);
|
||||
rpc Delete(DeleteRequest) returns (DeleteResponse);
|
||||
rpc Join(JoinRequest) returns (stream Message);
|
||||
rpc Invite(InviteRequest) returns (InviteResponse);
|
||||
rpc Leave(LeaveRequest) returns (LeaveResponse);
|
||||
rpc Kick(KickRequest) returns (KickResponse);
|
||||
}
|
||||
|
||||
// Create a new chat room
|
||||
message NewRequest {
|
||||
// name of the room
|
||||
string name = 1;
|
||||
// chat description
|
||||
string description = 2;
|
||||
// optional list of user ids
|
||||
repeated string user_ids = 3;
|
||||
// whether its a private room
|
||||
bool private = 4;
|
||||
}
|
||||
|
||||
message NewResponse {
|
||||
// the unique chat room
|
||||
Room room = 1;
|
||||
}
|
||||
|
||||
// List the messages in a chat
|
||||
message HistoryRequest {
|
||||
// the chat room id to get
|
||||
string room_id = 1;
|
||||
}
|
||||
|
||||
// HistoryResponse contains the historical messages in a chat
|
||||
message HistoryResponse {
|
||||
// messages in the chat room
|
||||
repeated Message messages = 1;
|
||||
}
|
||||
|
||||
// List available chats
|
||||
message ListRequest {
|
||||
// optional user id to filter by
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
repeated Room rooms = 1;
|
||||
}
|
||||
|
||||
// Delete a chat room
|
||||
message DeleteRequest {
|
||||
// the chat room id to delete
|
||||
string room_id = 1;
|
||||
}
|
||||
|
||||
message DeleteResponse {
|
||||
Room room = 1;
|
||||
}
|
||||
|
||||
|
||||
// Connect to a chat to receive a stream of messages
|
||||
// Send a message to a chat
|
||||
message SendRequest {
|
||||
// a client side id, should be validated by the server to make the request retry safe
|
||||
string client = 1;
|
||||
// id of the chat room the message is being sent to / from
|
||||
string room_id = 2;
|
||||
// id of the user who sent the message
|
||||
string user_id = 3;
|
||||
// subject of the message
|
||||
string subject = 4;
|
||||
// text of the message
|
||||
string text = 5;
|
||||
}
|
||||
|
||||
message SendResponse {
|
||||
// the message which was created
|
||||
Message message = 1;
|
||||
}
|
||||
|
||||
// Join a chat room
|
||||
message JoinRequest {
|
||||
// chat room to join
|
||||
string room_id = 1;
|
||||
// user id joining
|
||||
string user_id = 2;
|
||||
}
|
||||
|
||||
message Room {
|
||||
// unique room id
|
||||
string id = 1;
|
||||
// name of the chat
|
||||
string name = 2;
|
||||
// description of the that
|
||||
string description = 3;
|
||||
// time of creation
|
||||
string created_at = 4;
|
||||
// list of users
|
||||
repeated string user_ids = 5;
|
||||
// whether its a private room
|
||||
bool private = 6;
|
||||
}
|
||||
|
||||
// Message sent to a chat
|
||||
message Message {
|
||||
// id of the message, allocated by the server
|
||||
string id = 1;
|
||||
// a client side id, should be validated by the server to make the request retry safe
|
||||
string client = 2;
|
||||
// id of the chat the message is being sent to / from
|
||||
string room_id = 3;
|
||||
// id of the user who sent the message
|
||||
string user_id = 4;
|
||||
// time the message was sent in RFC3339 format
|
||||
string sent_at = 5;
|
||||
// subject of the message
|
||||
string subject = 6;
|
||||
// text of the message
|
||||
string text = 7;
|
||||
}
|
||||
|
||||
// Leave a chat room
|
||||
message LeaveRequest {
|
||||
// the chat room id
|
||||
string room_id = 1;
|
||||
// the user id
|
||||
string user_id = 2;
|
||||
}
|
||||
|
||||
message LeaveResponse {
|
||||
Room room = 1;
|
||||
}
|
||||
|
||||
// Invite a user to a chat room
|
||||
message InviteRequest {
|
||||
// the room id
|
||||
string room_id = 1;
|
||||
// the user id
|
||||
string user_id = 2;
|
||||
}
|
||||
|
||||
message InviteResponse {
|
||||
Room room = 1;
|
||||
}
|
||||
|
||||
// Kick a user from a chat room
|
||||
message KickRequest {
|
||||
// the chat room id
|
||||
string room_id = 1;
|
||||
// the user id
|
||||
string user_id = 2;
|
||||
}
|
||||
|
||||
message KickResponse {
|
||||
Room room = 1;
|
||||
}
|
||||
6
chat/publicapi.json
Normal file
6
chat/publicapi.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "chat",
|
||||
"icon": "💬",
|
||||
"category": "messaging",
|
||||
"display_name": "Chat"
|
||||
}
|
||||
Reference in New Issue
Block a user