add chat2 example

This commit is contained in:
Richard Ramos 2021-04-04 13:06:17 -04:00
parent 56346c6b1a
commit 47752170e9
No known key found for this signature in database
GPG Key ID: 80D4B01265FDFE8F
11 changed files with 1817 additions and 8 deletions

6
examples/chat2/Makefile Normal file
View File

@ -0,0 +1,6 @@
.PHONY: all build
build:
go build -o build/chat2 .
all: build

63
examples/chat2/README.md Normal file
View File

@ -0,0 +1,63 @@
# Using the `chat2` application
## Background
The `chat2` application is a basic command-line chat app using the [Waku v2 suite of protocols](https://specs.vac.dev/specs/waku/v2/waku-v2). It connects to a [fleet of test nodes](fleets.status.im) to provide end-to-end p2p chat capabilities. The Waku team is currently using this application for internal testing. If you want try our protocols, or join the dogfooding fun, follow the instructions below.
## Preparation
```
make
```
## Basic application usage
To start the `chat2` application in its most basic form, run the following from the project directory
```
./build/chat2
```
The app will randomly select and connect to a peer from the test fleet.
```
No static peers configured. Choosing one at random from test fleet...
```
Wait for the chat prompt (`>`) and chat away!
## Retrieving historical messages
TODO
## Specifying a static peer
In order to connect to a *specific* node as [`relay`](https://specs.vac.dev/specs/waku/v2/waku-relay) peer, define that node's `multiaddr` as a `staticnode` when starting the app:
```
./build/chat2 -staticnode=/ip4/134.209.139.210/tcp/30303/p2p/16Uiu2HAmPLe7Mzm8TsYUubgCAW1aJoeFScxrLj8ppHFivPo97bUZ
```
This will bypass the random peer selection process and connect to the specified node.
## In-chat options
| Command | Effect |
| --- | --- |
| `/help` | displays available in-chat commands |
| `/connect` | interactively connect to a new peer |
| `/nick` | change nickname for current chat session |
| `/peers` | Display the list of connected peers |
## `chat2` message protobuf format
Each `chat2` message is encoded as follows
```protobuf
message Chat2Message {
uint64 timestamp = 1;
string nick = 2;
bytes payload = 3;
}
```
where `timestamp` is the Unix timestamp of the message, `nick` is the relevant `chat2` user's selected nickname and `payload` is the actual chat message being sent. The `payload` is the byte array representation of a UTF8 encoded string.

2
examples/chat2/build/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

104
examples/chat2/chat.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"chat2/pb"
"context"
"encoding/binary"
"time"
"github.com/golang/protobuf/proto"
"github.com/libp2p/go-libp2p-core/peer"
"github.com/status-im/go-waku/waku/v2/node"
"github.com/status-im/go-waku/waku/v2/protocol"
)
var contentTopic uint32 = binary.LittleEndian.Uint32([]byte("dingpu"))
// Chat represents a subscription to a single PubSub topic. Messages
// can be published to the topic with Chat.Publish, and received
// messages are pushed to the Messages channel.
type Chat struct {
// Messages is a channel of messages received from other peers in the chat room
Messages chan *pb.Chat2Message
ctx context.Context
sub *node.Subscription
node *node.WakuNode
self peer.ID
nick string
}
// NewChat tries to subscribe to the PubSub topic for the room name, returning
// a ChatRoom on success.
func NewChat(ctx context.Context, n *node.WakuNode, selfID peer.ID, nickname string) (*Chat, error) {
// join the default waku topic and subscribe to it
sub, err := n.Subscribe(nil)
if err != nil {
return nil, err
}
c := &Chat{
ctx: ctx,
node: n,
sub: sub,
self: selfID,
nick: nickname,
Messages: make(chan *pb.Chat2Message, 1024),
}
// start reading messages from the subscription in a loop
go c.readLoop()
return c, nil
}
// Publish sends a message to the pubsub topic.
func (cr *Chat) Publish(message string) error {
msg := &pb.Chat2Message{
Timestamp: uint64(time.Now().Unix()),
Nick: cr.nick,
Payload: []byte(message),
}
msgBytes, err := proto.Marshal(msg)
if err != nil {
return err
}
var version uint32 = 0
var timestamp float64 = float64(time.Now().Unix()) / 1000000000
payload, err := node.Encode(msgBytes, &node.KeyInfo{Kind: node.None}, 0)
if err != nil {
return err
}
wakuMsg := &protocol.WakuMessage{
Payload: payload,
Version: &version,
ContentTopic: &contentTopic,
Timestamp: &timestamp,
}
return cr.node.Publish(wakuMsg, nil)
}
// readLoop pulls messages from the pubsub topic and pushes them onto the Messages channel.
func (cr *Chat) readLoop() {
for value := range cr.sub.C {
payload, err := node.DecodePayload(value.Message(), &node.KeyInfo{Kind: node.None})
if err != nil {
continue
}
msg := &pb.Chat2Message{}
if err := proto.Unmarshal(payload, msg); err != nil {
continue
}
// send valid messages onto the Messages channel
cr.Messages <- msg
}
}

16
examples/chat2/go.mod Normal file
View File

@ -0,0 +1,16 @@
module chat2
go 1.15
replace github.com/status-im/go-waku => /home/richard/status/go-waku
require (
github.com/ethereum/go-ethereum v1.10.1
github.com/gdamore/tcell/v2 v2.2.0
github.com/golang/protobuf v1.4.3
github.com/ipfs/go-log/v2 v2.1.1
github.com/libp2p/go-libp2p-core v0.8.5
github.com/rivo/tview v0.0.0-20210312174852-ae9464cc3598
github.com/status-im/go-waku v0.0.0-00010101000000-000000000000
google.golang.org/protobuf v1.25.0
)

1069
examples/chat2/go.sum Normal file

File diff suppressed because it is too large Load Diff

163
examples/chat2/main.go Normal file
View File

@ -0,0 +1,163 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
mrand "math/rand"
"net"
"net/http"
"os"
"time"
"github.com/ethereum/go-ethereum/crypto"
"github.com/libp2p/go-libp2p-core/peer"
"github.com/status-im/go-waku/waku/v2/node"
)
func main() {
mrand.Seed(time.Now().UTC().UnixNano())
nickFlag := flag.String("nick", "", "nickname to use in chat. will be generated if empty")
nodeKeyFlag := flag.String("nodekey", "", "private key for this node. will be generated if empty")
staticNodeFlag := flag.String("staticnode", "", "connects to a node. will get a random node from fleets.status.im if empty")
port := flag.Int("port", 0, "port. Will be random if 0")
flag.Parse()
hostAddr, _ := net.ResolveTCPAddr("tcp", fmt.Sprintf("0.0.0.0:%d", *port))
// use the nickname from the cli flag, or a default if blank
nodekey := *nodeKeyFlag
if len(nodekey) == 0 {
var err error
nodekey, err = randomHex(32)
if err != nil {
fmt.Println("Could not generate random key")
return
}
}
prvKey, err := crypto.HexToECDSA(nodekey)
ctx := context.Background()
wakuNode, err := node.New(ctx, prvKey, []net.Addr{hostAddr})
if err != nil {
fmt.Print(err)
return
}
wakuNode.MountRelay()
// use the nickname from the cli flag, or a default if blank
nick := *nickFlag
if len(nick) == 0 {
nick = defaultNick(wakuNode.Host().ID())
}
// join the chat
chat, err := NewChat(ctx, wakuNode, wakuNode.Host().ID(), nick)
if err != nil {
panic(err)
}
ui := NewChatUI(chat)
// Connect to a static node or use random node from fleets.status.im
go func() {
time.Sleep(200 * time.Millisecond)
staticnode := *staticNodeFlag
if len(staticnode) == 0 {
ui.displayMessage("No static peers configured. Choosing one at random from test fleet...")
staticnode = getRandomFleetNode()
}
err = wakuNode.DialPeer(staticnode)
if err != nil {
ui.displayMessage("Could not connect to peer: " + err.Error())
return
} else {
ui.displayMessage("Connected to peer: " + staticnode)
}
}()
// draw the UI
if err = ui.Run(); err != nil {
printErr("error running text UI: %s", err)
}
}
// Generates a random hex string with a length of n
func randomHex(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// printErr is like fmt.Printf, but writes to stderr.
func printErr(m string, args ...interface{}) {
fmt.Fprintf(os.Stderr, m, args...)
}
// defaultNick generates a nickname based on the $USER environment variable and
// the last 8 chars of a peer ID.
func defaultNick(p peer.ID) string {
return fmt.Sprintf("%s-%s", os.Getenv("USER"), shortID(p))
}
// shortID returns the last 8 chars of a base58-encoded peer id.
func shortID(p peer.ID) string {
pretty := p.Pretty()
return pretty[len(pretty)-8:]
}
func getRandomFleetNode() string {
url := "https://fleets.status.im"
httpClient := http.Client{
Timeout: time.Second * 2,
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Fatal(err)
}
res, getErr := httpClient.Do(req)
if getErr != nil {
log.Fatal(getErr)
}
if res.Body != nil {
defer res.Body.Close()
}
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Fatal(readErr)
}
var result map[string]interface{}
json.Unmarshal(body, &result)
fleets := result["fleets"].(map[string]interface{})
wakuv2Test := fleets["wakuv2.test"].(map[string]interface{})
waku := wakuv2Test["waku"].(map[string]interface{})
var wakunodes []string
for v := range waku {
wakunodes = append(wakunodes, v)
break
}
randKey := wakunodes[mrand.Intn(len(wakunodes))]
return waku[randKey].(string)
}

View File

@ -0,0 +1,165 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.25.0
// protoc v3.14.0
// source: chat2.proto
package pb
import (
proto "github.com/golang/protobuf/proto"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// This is a compile-time assertion that a sufficiently up-to-date version
// of the legacy proto package is being used.
const _ = proto.ProtoPackageIsVersion4
type Chat2Message struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Timestamp uint64 `protobuf:"varint,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
Nick string `protobuf:"bytes,2,opt,name=nick,proto3" json:"nick,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
}
func (x *Chat2Message) Reset() {
*x = Chat2Message{}
if protoimpl.UnsafeEnabled {
mi := &file_chat2_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Chat2Message) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Chat2Message) ProtoMessage() {}
func (x *Chat2Message) ProtoReflect() protoreflect.Message {
mi := &file_chat2_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Chat2Message.ProtoReflect.Descriptor instead.
func (*Chat2Message) Descriptor() ([]byte, []int) {
return file_chat2_proto_rawDescGZIP(), []int{0}
}
func (x *Chat2Message) GetTimestamp() uint64 {
if x != nil {
return x.Timestamp
}
return 0
}
func (x *Chat2Message) GetNick() string {
if x != nil {
return x.Nick
}
return ""
}
func (x *Chat2Message) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
var File_chat2_proto protoreflect.FileDescriptor
var file_chat2_proto_rawDesc = []byte{
0x0a, 0x0b, 0x63, 0x68, 0x61, 0x74, 0x32, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70,
0x62, 0x22, 0x5a, 0x0a, 0x0c, 0x43, 0x68, 0x61, 0x74, 0x32, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01,
0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12,
0x12, 0x0a, 0x04, 0x6e, 0x69, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
0x69, 0x63, 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_chat2_proto_rawDescOnce sync.Once
file_chat2_proto_rawDescData = file_chat2_proto_rawDesc
)
func file_chat2_proto_rawDescGZIP() []byte {
file_chat2_proto_rawDescOnce.Do(func() {
file_chat2_proto_rawDescData = protoimpl.X.CompressGZIP(file_chat2_proto_rawDescData)
})
return file_chat2_proto_rawDescData
}
var file_chat2_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_chat2_proto_goTypes = []interface{}{
(*Chat2Message)(nil), // 0: pb.Chat2Message
}
var file_chat2_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_chat2_proto_init() }
func file_chat2_proto_init() {
if File_chat2_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_chat2_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Chat2Message); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_chat2_proto_rawDesc,
NumEnums: 0,
NumMessages: 1,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_chat2_proto_goTypes,
DependencyIndexes: file_chat2_proto_depIdxs,
MessageInfos: file_chat2_proto_msgTypes,
}.Build()
File_chat2_proto = out.File
file_chat2_proto_rawDesc = nil
file_chat2_proto_goTypes = nil
file_chat2_proto_depIdxs = nil
}

View File

@ -0,0 +1,9 @@
syntax = "proto3";
package pb;
message Chat2Message {
uint64 timestamp = 1;
string nick = 2;
bytes payload = 3;
}

208
examples/chat2/ui.go Normal file
View File

@ -0,0 +1,208 @@
package main
import (
"chat2/pb"
"fmt"
"io"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/status-im/go-waku/waku/v2/node"
)
// ChatUI is a Text User Interface (TUI) for a ChatRoom.
// The Run method will draw the UI to the terminal in "fullscreen"
// mode. You can quit with Ctrl-C, or by typing "/quit" into the
// chat prompt.
type ChatUI struct {
app *tview.Application
chat *Chat
msgW io.Writer
inputCh chan string
doneCh chan struct{}
}
// NewChatUI returns a new ChatUI struct that controls the text UI.
// It won't actually do anything until you call Run().
func NewChatUI(chat *Chat) *ChatUI {
chatUI := new(ChatUI)
app := tview.NewApplication()
// make a NewChatUI text view to contain our chat messages
msgBox := tview.NewTextView()
msgBox.SetDynamicColors(true)
msgBox.SetBorder(true)
msgBox.SetTitle("chat2 example")
// text views are io.Writers, but they don't automatically refresh.
// this sets a change handler to force the app to redraw when we get
// new messages to display.
msgBox.SetChangedFunc(func() {
app.Draw()
})
// an input field for typing messages into
inputCh := make(chan string, 32)
input := tview.NewInputField().
SetLabel(chat.nick + " > ").
SetFieldWidth(0).
SetFieldBackgroundColor(tcell.ColorBlack)
// the done func is called when the user hits enter, or tabs out of the field
input.SetDoneFunc(func(key tcell.Key) {
if key != tcell.KeyEnter {
// we don't want to do anything if they just tabbed away
return
}
line := input.GetText()
if len(line) == 0 {
// ignore blank lines
return
}
input.SetText("")
// bail if requested
if line == "/quit" {
app.Stop()
return
}
// add peer
if strings.HasPrefix(line, "/connect ") {
peer := strings.TrimPrefix(line, "/connect ")
go func(peer string) {
chatUI.displayMessage("Connecting to peer...")
err := chat.node.DialPeer(peer)
if err != nil {
chatUI.displayMessage(err.Error())
} else {
chatUI.displayMessage("Peer connected succesfully")
}
}(peer)
return
}
// list peers
if line == "/peers" {
peers := chat.node.PubSub().ListPeers(string(node.DefaultWakuTopic))
if len(peers) == 0 {
chatUI.displayMessage("No peers available")
}
for _, p := range peers {
chatUI.displayMessage("- " + p.Pretty())
}
return
}
// change nick
if strings.HasPrefix(line, "/nick ") {
newNick := strings.TrimSpace(strings.TrimPrefix(line, "/nick "))
chat.nick = newNick
input.SetLabel(chat.nick + " > ")
return
}
if line == "/help" {
chatUI.displayMessage(`
Available commands:
/connect multiaddress - dials a node adding it to the list of connected peers
/peers - list of peers connected to this node
/nick newNick - change the user's nickname
/quit - closes the app
`)
return
}
// send the line onto the input chan and reset the field text
inputCh <- line
})
chatPanel := tview.NewFlex().
AddItem(msgBox, 0, 1, false)
// flex is a vertical box with the chatPanel on top and the input field at the bottom.
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(chatPanel, 0, 1, false).
AddItem(input, 1, 1, true)
app.SetRoot(flex, true)
chatUI.app = app
chatUI.msgW = msgBox
chatUI.chat = chat
chatUI.inputCh = inputCh
chatUI.doneCh = make(chan struct{}, 1)
for _, addr := range chat.node.ListenAddresses() {
chatUI.displayMessage(fmt.Sprintf("Listening on %s", addr))
}
return chatUI
}
// Run starts the chat event loop in the background, then starts
// the event loop for the text UI.
func (ui *ChatUI) Run() error {
ui.displayMessage("\nWelcome, " + ui.chat.nick)
ui.displayMessage("type /help to see available commands \n")
go ui.handleEvents()
defer ui.end()
return ui.app.Run()
}
// end signals the event loop to exit gracefully
func (ui *ChatUI) end() {
ui.doneCh <- struct{}{}
}
// displayChatMessage writes a ChatMessage from the room to the message window,
// with the sender's nick highlighted in green.
func (ui *ChatUI) displayChatMessage(cm *pb.Chat2Message) {
t := time.Unix(int64(cm.Timestamp), 0)
prompt := withColor("green", fmt.Sprintf("<%s> %s:", t.Format("Jan 02, 15:04"), cm.Nick))
fmt.Fprintf(ui.msgW, "%s %s\n", prompt, cm.Payload)
}
// displayMessage writes a blue message to output
func (ui *ChatUI) displayMessage(msg string) {
fmt.Fprintf(ui.msgW, "%s\n", withColor("grey", msg))
}
// handleEvents runs an event loop that sends user input to the chat room
// and displays messages received from the chat room. It also periodically
// refreshes the list of peers in the UI.
func (ui *ChatUI) handleEvents() {
for {
select {
case input := <-ui.inputCh:
err := ui.chat.Publish(input)
if err != nil {
printErr("publish error: %s", err)
}
case m := <-ui.chat.Messages:
// when we receive a message from the chat room, print it to the message window
ui.displayChatMessage(m)
case <-ui.chat.ctx.Done():
return
case <-ui.doneCh:
return
}
}
}
// withColor wraps a string with color tags for display in the messages text box.
func withColor(color, msg string) string {
return fmt.Sprintf("[%s]%s[-]", color, msg)
}

20
main.go
View File

@ -18,15 +18,14 @@ import (
func main() {
golog.SetAllLoggers(golog.LevelInfo) // Change to INFO for extra info
hostAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:60001")
extAddr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:60001")
hostAddr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:60001")
key := "9ceff459635becbab13190132172fc9612357696c176a9e2b6e22f28a73a54de"
prvKey, err := crypto.HexToECDSA(key)
ctx := context.Background()
wakuNode, err := node.New(ctx, prvKey, hostAddr, extAddr)
wakuNode, err := node.New(ctx, prvKey, []net.Addr{hostAddr})
if err != nil {
fmt.Print(err)
}
@ -42,7 +41,7 @@ func main() {
// Read loop
go func() {
for value := range sub.C {
payload, err := node.DecodePayload(value, &node.KeyInfo{Kind: node.None})
payload, err := node.DecodePayload(value.Message(), &node.KeyInfo{Kind: node.None})
if err != nil {
fmt.Println(err)
return
@ -61,9 +60,16 @@ func main() {
var contentTopic uint32 = 1
var version uint32 = 0
var timestamp float64 = float64(time.Now().Unix()) / 1000000000
payload, err := node.Encode([]byte("Hello World"), &node.KeyInfo{Kind: node.None}, 0)
msg := &protocol.WakuMessage{Payload: payload, Version: &version, ContentTopic: &contentTopic}
msg := &protocol.WakuMessage{
Payload: payload,
Version: &version,
ContentTopic: &contentTopic,
Timestamp: &timestamp,
}
err = wakuNode.Publish(msg, nil)
if err != nil {
fmt.Println("Error sending a message", err)
@ -80,7 +86,5 @@ func main() {
fmt.Println("\n\n\nReceived signal, shutting down...")
// shut the node down
if err := wakuNode.Stop(); err != nil {
panic(err)
}
wakuNode.Stop()
}