Skip to content

Commit

Permalink
feat: pkg/bertybot + cmd/testbot
Browse files Browse the repository at this point in the history
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
  • Loading branch information
moul committed Oct 9, 2020
1 parent 3e65127 commit cf0adaf
Show file tree
Hide file tree
Showing 10 changed files with 743 additions and 1 deletion.
2 changes: 1 addition & 1 deletion go/cmd/betabot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ func safeDefaultDisplayName() string {
if name == "" {
name = "Anonymous4242"
}
return fmt.Sprintf("%s (bot)", name)
return fmt.Sprintf("%s (betabot)", name)
}

func getRandomReply() string {
Expand Down
96 changes: 96 additions & 0 deletions go/cmd/testbot/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"context"
"flag"
"fmt"
"math/rand"
"os"
"os/user"

qrterminal "github.com/mdp/qrterminal/v3"
"go.uber.org/zap"
"moul.io/srand"
"moul.io/u"
"moul.io/zapconfig"

"berty.tech/berty/v2/go/pkg/bertybot"
)

var (
nodeAddr = flag.String("addr", "127.0.0.1:9091", "remote 'berty daemon' address")
displayName = flag.String("display-name", safeDefaultDisplayName(), "bot's display name")
debug = flag.Bool("debug", false, "debug mode")
skipReplay = flag.Bool("skip-replay", true, "skip replay")
replyDelay = flag.Duration("reply-delay", 0, "reply delay")
)

func main() {
flag.Parse()
rand.Seed(srand.MustSecure())
if err := Main(); err != nil {
fmt.Fprintf(os.Stderr, "error: %+v\n", err)
os.Exit(1)
}
}

func Main() error {
rootLogger := zapconfig.Configurator{}.MustBuildLogger()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logger := rootLogger.Named("testbot")

// init bot
opts := []bertybot.NewOption{}
opts = append(opts,
bertybot.WithLogger(logger.Named("botlib")), // configure a logger
bertybot.WithDisplayName(*displayName), // bot name
bertybot.WithInsecureMessengerGRPCAddr(*nodeAddr), // connect to running berty messenger daemon
bertybot.WithSkipAcknowledge(), // skip acknowledge events
bertybot.WithSkipMyself(), // skip my own interactions
bertybot.WithRecipe(bertybot.DelayResponseRecipe(*replyDelay)), // add a delay before sending replies
bertybot.WithRecipe(bertybot.AutoAcceptIncomingContactRequestRecipe()), // accept incoming contact requests
bertybot.WithRecipe(bertybot.WelcomeMessageRecipe("welcome to testbot")), // send welcome message to new contacts and new conversations
bertybot.WithRecipe(bertybot.EchoRecipe("you said: ")), // reply to messages with the same message
)
if *skipReplay {
opts = append(opts, bertybot.WithSkipReplay()) // skip old events, only consume fresh ones
}
if *debug {
opts = append(opts, bertybot.WithRecipe(bertybot.DebugEventRecipe(rootLogger.Named("debug")))) // debug events
}
bot, err := bertybot.New(opts...)
if err != nil {
return fmt.Errorf("bot initialization failed: %w", err)
}
// display link and qr code
logger.Info("retrieve instance Berty ID",
zap.String("pk", bot.PublicKey()),
zap.String("link", bot.BertyIDURL()),
)
qrterminal.GenerateHalfBlock(bot.BertyIDURL(), qrterminal.L, os.Stdout)

// signal handling
go func() {
u.WaitForCtrlC()
cancel()
}()

// start bot
return bot.Start(ctx)
}

func safeDefaultDisplayName() string {
var name string
current, err := user.Current()
if err == nil {
name = current.Username
}
if name == "" {
name = os.Getenv("USER")
}
if name == "" {
name = "anon"
}
return fmt.Sprintf("%s (testbot)", name)
}
116 changes: 116 additions & 0 deletions go/pkg/bertybot/bot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package bertybot

import (
"context"
"fmt"
"sync"
"time"

"go.uber.org/zap"
"moul.io/u"

"berty.tech/berty/v2/go/pkg/bertymessenger"
)

type Bot struct {
client bertymessenger.MessengerServiceClient
logger *zap.Logger
displayName string
bertyID *bertymessenger.InstanceShareableBertyID_Reply
skipReplay bool
skipAcknowledge bool
skipMyself bool
handlers map[HandlerType][]Handler
isReplaying bool
handledEvents uint
store struct {
conversations map[string]*bertymessenger.Conversation
mutex sync.Mutex
}
}

// New initializes a new Bot.
// The order of the passed options may have an impact.
func New(opts ...NewOption) (*Bot, error) {
b := Bot{
logger: zap.NewNop(),
handlers: make(map[HandlerType][]Handler),
}
b.store.conversations = make(map[string]*bertymessenger.Conversation)

// configure bot with options
for _, opt := range opts {
if err := opt(&b); err != nil {
return nil, fmt.Errorf("bot: opt failed: %w", err)
}
}

// check minimal requirements
if b.client == nil {
return nil, fmt.Errorf("bot: missing messenger client")
}

// apply defaults
if b.displayName == "" {
b.displayName = "My Berty Bot"
}

// retrieve Berty ID to check if everything is well configured, and cache it for easy access
{
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &bertymessenger.InstanceShareableBertyID_Request{
DisplayName: b.displayName,
}
ret, err := b.client.InstanceShareableBertyID(ctx, req)
if err != nil {
return nil, fmt.Errorf("bot: cannot retrieve berty ID: %w", err)
}
b.bertyID = ret
}

return &b, nil
}

// BertyIDURL returns the shareable Berty ID in the form of `https://berty.tech/id#xxx`.
func (b *Bot) BertyIDURL() string {
return b.bertyID.HTMLURL
}

// PublicKey returns the public key of the messenger node.
func (b *Bot) PublicKey() string {
return u.B64Encode(b.bertyID.BertyID.AccountPK)
}

// Start starts the main event loop and can be stopped by canceling the passed context.
func (b *Bot) Start(ctx context.Context) error {
b.logger.Info("connecting to the event stream")
s, err := b.client.EventStream(ctx, &bertymessenger.EventStream_Request{})
if err != nil {
return fmt.Errorf("failed to listen to EventStream: %w", err)
}

b.isReplaying = true
for {
gme, err := s.Recv()
if err != nil {
return fmt.Errorf("stream error: %w", err)
}

if b.isReplaying {
if gme.Event.Type == bertymessenger.StreamEvent_TypeListEnd {
b.logger.Info("finished replaying logs from the previous sessions", zap.Uint("count", b.handledEvents))
b.isReplaying = false
}
b.handledEvents++

if b.skipReplay {
continue
}
}

if err := b.handleEvent(ctx, gme.Event); err != nil {
b.logger.Error("bot.handleEvent failed", zap.Error(err))
}
}
}
3 changes: 3 additions & 0 deletions go/pkg/bertybot/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package bertybot

type Command struct{}
60 changes: 60 additions & 0 deletions go/pkg/bertybot/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package bertybot

import (
"context"
"fmt"

"github.com/gogo/protobuf/proto"
"go.uber.org/zap"

"berty.tech/berty/v2/go/pkg/bertymessenger"
)

// Context is the main argument passed to handlers.
type Context struct {
// common
HandlerType HandlerType
EventPayload proto.Message `json:"-"` // content of the payload is already available in the parsed payloads
EventType bertymessenger.StreamEvent_Type
Context context.Context
Client bertymessenger.MessengerServiceClient
Logger *zap.Logger
IsReplay bool // whether the event is a replayed or a fresh event
IsMe bool // whether the bot is the author
IsAck bool // whether the event is an ack

// parsed payloads, depending on the context
Contact *bertymessenger.Contact `json:"Contact,omitempty"`
Conversation *bertymessenger.Conversation `json:"Conversation,omitempty"`
Interaction *bertymessenger.Interaction `json:"Interaction,omitempty"`
Member *bertymessenger.Member `json:"Member,omitempty"`
Account *bertymessenger.Account `json:"Account,omitempty"`
Device *bertymessenger.Device `json:"Device,omitempty"`
ConversationPK string `json:"ConversationPK,omitempty"`
UserMessage string `json:"UserMessage,omitempty"`

// internal
initialized bool
}

// ReplyString sends a text message on the conversation related to the context.
// The conversation can be 1-1 or multi-member.
func (ctx *Context) ReplyString(text string) error {
if ctx.ConversationPK == "" {
return fmt.Errorf("unknown conversation PK, cannot reply")
}
// FIXME: support group conversation
userMessage, err := proto.Marshal(&bertymessenger.AppMessage_UserMessage{Body: text})
if err != nil {
return fmt.Errorf("marshal user message failed: %w", err)
}
_, err = ctx.Client.Interact(ctx.Context, &bertymessenger.Interact_Request{
Type: bertymessenger.AppMessage_TypeUserMessage,
Payload: userMessage,
ConversationPublicKey: ctx.ConversationPK,
})
if err != nil {
return fmt.Errorf("interact failed: %w", err)
}
return nil
}
50 changes: 50 additions & 0 deletions go/pkg/bertybot/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package bertybot_test

import (
"context"
"os"
"time"

qrterminal "github.com/mdp/qrterminal/v3"
"go.uber.org/zap"
"moul.io/u"

"berty.tech/berty/v2/go/pkg/bertybot"
)

func Example() {
logger, _ := zap.NewDevelopment()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// init bot
bot, _ := bertybot.New(
bertybot.WithLogger(logger.Named("botlib")), // configure a logger
bertybot.WithDisplayName("example bot"), // bot name
bertybot.WithInsecureMessengerGRPCAddr("127.0.0.1:9091"), // connect to running berty messenger daemon
bertybot.WithSkipReplay(), // skip old events, only consume fresh ones
bertybot.WithSkipAcknowledge(), // skip acknowledge events
bertybot.WithSkipMyself(), // skip my own interactions
bertybot.WithRecipe(bertybot.DebugEventRecipe(logger.Named("debug"))), // debug events
bertybot.WithRecipe(bertybot.DelayResponseRecipe(time.Second)), // add a delay before sending replies
bertybot.WithRecipe(bertybot.AutoAcceptIncomingContactRequestRecipe()), // accept incoming contact requests
bertybot.WithRecipe(bertybot.WelcomeMessageRecipe("welcome to example bot")), // send welcome message to new contacts and new conversations
bertybot.WithRecipe(bertybot.EchoRecipe("you said: ")), // reply to messages with the same message
bertybot.WithHandler(bertybot.UserMessageHandler, func(ctx bertybot.Context) { // custom handler
ctx.ReplyString("hello world!")
}),
)

// display link and qr code
logger.Info("retrieve instance Berty ID", zap.String("pk", bot.PublicKey()), zap.String("link", bot.BertyIDURL()))
qrterminal.GenerateHalfBlock(bot.BertyIDURL(), qrterminal.L, os.Stdout)

// signal handling
go func() {
u.WaitForCtrlC()
cancel()
}()

// start bot
bot.Start(ctx)
}

0 comments on commit cf0adaf

Please sign in to comment.