From 7faab93f9e6a1f8d5c8ab5b8c80ae6d8fa86d03a Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Fri, 31 May 2019 00:18:41 +0100 Subject: [PATCH] Add agent => bot --- agent/.travis.yml | 7 ++ agent/README.md | 197 +++++++++++++++++++++++++++++ agent/agent.go | 2 + agent/command/command.go | 54 ++++++++ agent/command/command_test.go | 65 ++++++++++ agent/input/discord/README.md | 22 ++++ agent/input/discord/conn.go | 94 ++++++++++++++ agent/input/discord/discord.go | 153 ++++++++++++++++++++++ agent/input/input.go | 55 ++++++++ agent/input/slack/conn.go | 160 +++++++++++++++++++++++ agent/input/slack/slack.go | 147 ++++++++++++++++++++++ agent/input/telegram/README.md | 18 +++ agent/input/telegram/conn.go | 115 +++++++++++++++++ agent/input/telegram/telegram.go | 101 +++++++++++++++ agent/proto/bot.micro.go | 118 +++++++++++++++++ agent/proto/bot.pb.go | 210 +++++++++++++++++++++++++++++++ agent/proto/bot.proto | 25 ++++ 17 files changed, 1543 insertions(+) create mode 100644 agent/.travis.yml create mode 100644 agent/README.md create mode 100644 agent/agent.go create mode 100644 agent/command/command.go create mode 100644 agent/command/command_test.go create mode 100644 agent/input/discord/README.md create mode 100644 agent/input/discord/conn.go create mode 100644 agent/input/discord/discord.go create mode 100644 agent/input/input.go create mode 100644 agent/input/slack/conn.go create mode 100644 agent/input/slack/slack.go create mode 100644 agent/input/telegram/README.md create mode 100644 agent/input/telegram/conn.go create mode 100644 agent/input/telegram/telegram.go create mode 100644 agent/proto/bot.micro.go create mode 100644 agent/proto/bot.pb.go create mode 100644 agent/proto/bot.proto diff --git a/agent/.travis.yml b/agent/.travis.yml new file mode 100644 index 00000000..60a31c5f --- /dev/null +++ b/agent/.travis.yml @@ -0,0 +1,7 @@ +language: go +go: +- 1.10.x +- 1.11.x +notifications: + slack: + secure: j+1gw9+LEu6GNisO18UoYOT8x9SGGoy57xJTmSQcWLGmhlcRwdvRzBJTdnZQ/sItokJSufW4K4cVZy4749fq7EaHqMtcuFkwju/i5EVOHliOlTqvo6bZaCThvuSfknBmHK0oU3u0DZBDAqR7BGTRztB+8A4gUNTVYumkP1sVWqoF8lvkM276Il1oprWeRyAdCpBNDuiUlMC0SpJmfUKrqpE64xe1iOCedPTPj7cbgnl35NBll0poXPIp/XNYn4ZsNP100vb5LSyvGHO5Z5XB3LZJit3wNWB3zI9y5Dr4d2EAV/c1VWqalbbT0/CLvSEnP++ubZhnK7sTMhgYxIlMtNTT/81MplVTeKcS+UFBw/aeTzrjhx6g5FNQiN2cnurs/O0Xwoq0Oh01l4uTIU4xUGQFtba/WFPDAO7cEWk8T1Z/fjkD22bDvkm3C2XXsA5nXeIjLhODKRV2fc5/Slxi2p5znhVRFukxnvPh0vVWh8yeukyjYjQXQ2n7Cl+UDBlggWGtRAYOnNo1uhLPShVoJwc/NjnRX5vJPbnjkASkEutqWAw/Rmwh+wx5+WNiYjo/VE8ZJPKyRK+0dQqhOrP73HWJxO9sMFucymeFVcxa4z3B7Nn7TBo1BhCyAdohT9V19gqfRO+TA8kifz8BF8jWBYUBOSQZrg92bYGkia3ROMs= diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 00000000..bb6cba7c --- /dev/null +++ b/agent/README.md @@ -0,0 +1,197 @@ +# Agent + +Agent is a library used to create commands, inputs and robot services + +## Getting Started + +- [Commands](#commands) - Commands are functions executed by the bot based on text based pattern matching. +- [Inputs](#inputs) - Inputs are plugins for communication e.g Slack, Telegram, IRC, etc. +- [Services](#services) - Write bots as micro services + +## Commands + +Commands are functions executed by the bot based on text based pattern matching. + +### Write a Command + +```go +import "github.com/micro/go-bot/command" + +func Ping() command.Command { + usage := "ping" + description := "Returns pong" + + return command.NewCommand("ping", usage, desc, func(args ...string) ([]byte, error) { + return []byte("pong"), nil + }) +} +``` + +### Register the command + +Add the command to the Commands map with a pattern key that can be matched by golang/regexp.Match + +```go +import "github.com/micro/go-bot/command" + +func init() { + command.Commands["^ping$"] = Ping() +} +``` + +### Rebuild Micro + +Build binary +```shell +cd github.com/micro/micro + +// For local use +go build -i -o micro ./main.go + +// For docker image +CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-w' -i -o micro ./main.go +``` + +## Inputs + +Inputs are plugins for communication e.g Slack, HipChat, XMPP, IRC, SMTP, etc, etc. + +New inputs can be added in the following way. + +### Write an Input + +Write an input that satisfies the Input interface. + +```go +type Input interface { + // Provide cli flags + Flags() []cli.Flag + // Initialise input using cli context + Init(*cli.Context) error + // Stream events from the input + Stream() (Conn, error) + // Start the input + Start() error + // Stop the input + Stop() error + // name of the input + String() string +} +``` + +### Register the input + +Add the input to the Inputs map. + +```go +import "github.com/micro/micro/bot/input" + +func init() { + input.Inputs["name"] = MyInput +} +``` + +### Rebuild Micro + +Build binary +```shell +cd github.com/micro/micro + +// For local use +go build -i -o micro ./main.go + +// For docker image +CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-w' -i -o micro ./main.go +``` + +## Services + +The micro bot supports the ability to create commands as micro services. + +### How does it work? + +The bot watches the service registry for services with it's namespace. The default namespace is `go.micro.bot`. +Any service within this namespace will automatically be added to the list of available commands. When a command +is executed, the bot will call the service with method `Command.Exec`. It also expects the method `Command.Help` +to exist for usage info. + + +The service interface is as follows and can be found at [go-bot/proto](https://github.com/micro/go-bot/blob/master/proto/bot.proto) + +``` +syntax = "proto3"; + +package go.micro.bot; + +service Command { + rpc Help(HelpRequest) returns (HelpResponse) {}; + rpc Exec(ExecRequest) returns (ExecResponse) {}; +} + +message HelpRequest { +} + +message HelpResponse { + string usage = 1; + string description = 2; +} + +message ExecRequest { + repeated string args = 1; +} + +message ExecResponse { + bytes result = 1; + string error = 2; +} +``` + +### Example + +Here's an example echo command as a microservice + +```go +package main + +import ( + "fmt" + "strings" + + "github.com/micro/go-micro" + "golang.org/x/net/context" + + proto "github.com/micro/go-bot/proto" +) + +type Command struct{} + +// Help returns the command usage +func (c *Command) Help(ctx context.Context, req *proto.HelpRequest, rsp *proto.HelpResponse) error { + // Usage should include the name of the command + rsp.Usage = "echo" + rsp.Description = "This is an example bot command as a micro service which echos the message" + return nil +} + +// Exec executes the command +func (c *Command) Exec(ctx context.Context, req *proto.ExecRequest, rsp *proto.ExecResponse) error { + rsp.Result = []byte(strings.Join(req.Args, " ")) + // rsp.Error could be set to return an error instead + // the function error would only be used for service level issues + return nil +} + +func main() { + service := micro.NewService( + micro.Name("go.micro.bot.echo"), + ) + + service.Init() + + proto.RegisterCommandHandler(service.Server(), new(Command)) + + if err := service.Run(); err != nil { + fmt.Println(err) + } +} +``` diff --git a/agent/agent.go b/agent/agent.go new file mode 100644 index 00000000..359e8f71 --- /dev/null +++ b/agent/agent.go @@ -0,0 +1,2 @@ +// Package agent provides an interface for building robots +package agent diff --git a/agent/command/command.go b/agent/command/command.go new file mode 100644 index 00000000..92d670c8 --- /dev/null +++ b/agent/command/command.go @@ -0,0 +1,54 @@ +// Package command is an interface for defining bot commands +package command + +var ( + // Commmands keyed by golang/regexp patterns + // regexp.Match(key, input) is used to match + Commands = map[string]Command{} +) + +// Command is the interface for specific named +// commands executed via plugins or the bot. +type Command interface { + // Executes the command with args passed in + Exec(args ...string) ([]byte, error) + // Usage of the command + Usage() string + // Description of the command + Description() string + // Name of the command + String() string +} + +type cmd struct { + name string + usage string + description string + exec func(args ...string) ([]byte, error) +} + +func (c *cmd) Description() string { + return c.description +} + +func (c *cmd) Exec(args ...string) ([]byte, error) { + return c.exec(args...) +} + +func (c *cmd) Usage() string { + return c.usage +} + +func (c *cmd) String() string { + return c.name +} + +// NewCommand helps quickly create a new command +func NewCommand(name, usage, description string, exec func(args ...string) ([]byte, error)) Command { + return &cmd{ + name: name, + usage: usage, + description: description, + exec: exec, + } +} diff --git a/agent/command/command_test.go b/agent/command/command_test.go new file mode 100644 index 00000000..5f8c7cdc --- /dev/null +++ b/agent/command/command_test.go @@ -0,0 +1,65 @@ +package command + +import ( + "testing" +) + +func TestCommand(t *testing.T) { + c := &cmd{ + name: "test", + usage: "test usage", + description: "test description", + exec: func(args ...string) ([]byte, error) { + return []byte("test"), nil + }, + } + + if c.String() != c.name { + t.Fatalf("expected name %s got %s", c.name, c.String()) + } + + if c.Usage() != c.usage { + t.Fatalf("expected usage %s got %s", c.usage, c.Usage()) + } + + if c.Description() != c.description { + t.Fatalf("expected description %s got %s", c.description, c.Description()) + } + + if r, err := c.Exec(); err != nil { + t.Fatal(err) + } else if string(r) != "test" { + t.Fatalf("expected exec result test got %s", string(r)) + } +} + +func TestNewCommand(t *testing.T) { + c := &cmd{ + name: "test", + usage: "test usage", + description: "test description", + exec: func(args ...string) ([]byte, error) { + return []byte("test"), nil + }, + } + + nc := NewCommand(c.name, c.usage, c.description, c.exec) + + if nc.String() != c.name { + t.Fatalf("expected name %s got %s", c.name, nc.String()) + } + + if nc.Usage() != c.usage { + t.Fatalf("expected usage %s got %s", c.usage, nc.Usage()) + } + + if nc.Description() != c.description { + t.Fatalf("expected description %s got %s", c.description, nc.Description()) + } + + if r, err := nc.Exec(); err != nil { + t.Fatal(err) + } else if string(r) != "test" { + t.Fatalf("expected exec result test got %s", string(r)) + } +} diff --git a/agent/input/discord/README.md b/agent/input/discord/README.md new file mode 100644 index 00000000..7031f529 --- /dev/null +++ b/agent/input/discord/README.md @@ -0,0 +1,22 @@ +# Discord input for micro-bot +[Discord](https://discordapp.com) support for micro bot based on [discordgo](github.com/bwmarrin/discordgo). + +This was originally written by Aleksandr Tihomirov (@zet4) and can be found at https://github.com/zet4/micro-misc/. + +## Options +### discord_token + +You have to supply an application token via `--discord_token`. + +Head over to Discord's [developer introduction](https://discordapp.com/developers/docs/intro) +to learn how to create applications and how the API works. + +### discord_prefix + +Set a command prefix with `--discord_prefix`. The default prefix is `Micro `. +You can mention the bot or use the prefix to run a command. + +### discord_whitelist + +Pass a list of comma-separated user IDs with `--discord_whitelist`. Only allow +these users to use the bot. diff --git a/agent/input/discord/conn.go b/agent/input/discord/conn.go new file mode 100644 index 00000000..20a35af9 --- /dev/null +++ b/agent/input/discord/conn.go @@ -0,0 +1,94 @@ +package discord + +import ( + "errors" + "strings" + "sync" + + "github.com/bwmarrin/discordgo" + "github.com/micro/go-micro/agent/input" + "github.com/micro/go-log" +) + +type discordConn struct { + master *discordInput + exit chan struct{} + recv chan *discordgo.Message + + sync.Mutex +} + +func newConn(master *discordInput) *discordConn { + conn := &discordConn{ + master: master, + exit: make(chan struct{}), + recv: make(chan *discordgo.Message), + } + + conn.master.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author.ID == master.botID { + return + } + + whitelisted := false + for _, ID := range conn.master.whitelist { + if m.Author.ID == ID { + whitelisted = true + break + } + } + + if len(master.whitelist) > 0 && !whitelisted { + return + } + + var valid bool + m.Message.Content, valid = conn.master.prefixfn(m.Message.Content) + if !valid { + return + } + + conn.recv <- m.Message + }) + + return conn +} + +func (dc *discordConn) Recv(event *input.Event) error { + for { + select { + case <-dc.exit: + return errors.New("connection closed") + case msg := <-dc.recv: + + event.From = msg.ChannelID + ":" + msg.Author.ID + event.To = dc.master.botID + event.Type = input.TextEvent + event.Data = []byte(msg.Content) + return nil + } + } +} + +func (dc *discordConn) Send(e *input.Event) error { + fields := strings.Split(e.To, ":") + _, err := dc.master.session.ChannelMessageSend(fields[0], string(e.Data)) + if err != nil { + log.Log("[bot][loop][send]", err) + } + return nil +} + +func (dc *discordConn) Close() error { + if err := dc.master.session.Close(); err != nil { + return err + } + + select { + case <-dc.exit: + return nil + default: + close(dc.exit) + } + return nil +} diff --git a/agent/input/discord/discord.go b/agent/input/discord/discord.go new file mode 100644 index 00000000..05eb816a --- /dev/null +++ b/agent/input/discord/discord.go @@ -0,0 +1,153 @@ +package discord + +import ( + "fmt" + "sync" + + "errors" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/micro/cli" + "github.com/micro/go-micro/agent/input" +) + +func init() { + input.Inputs["discord"] = newInput() +} + +func newInput() *discordInput { + return &discordInput{} +} + +type discordInput struct { + token string + whitelist []string + prefix string + prefixfn func(string) (string, bool) + botID string + + session *discordgo.Session + + sync.Mutex + running bool + exit chan struct{} +} + +func (d *discordInput) Flags() []cli.Flag { + return []cli.Flag{ + cli.StringFlag{ + Name: "discord_token", + EnvVar: "MICRO_DISCORD_TOKEN", + Usage: "Discord token (prefix with Bot if it's for bot account)", + }, + cli.StringFlag{ + Name: "discord_whitelist", + EnvVar: "MICRO_DISCORD_WHITELIST", + Usage: "Discord Whitelist (seperated by ,)", + }, + cli.StringFlag{ + Name: "discord_prefix", + Usage: "Discord Prefix", + EnvVar: "MICRO_DISCORD_PREFIX", + Value: "Micro ", + }, + } +} + +func (d *discordInput) Init(ctx *cli.Context) error { + token := ctx.String("discord_token") + whitelist := ctx.String("discord_whitelist") + prefix := ctx.String("discord_prefix") + + if len(token) == 0 { + return errors.New("require token") + } + + d.token = token + d.prefix = prefix + + if len(whitelist) > 0 { + d.whitelist = strings.Split(whitelist, ",") + } + + return nil +} + +func (d *discordInput) Start() error { + if len(d.token) == 0 { + return errors.New("missing discord configuration") + } + + d.Lock() + defer d.Unlock() + + if d.running { + return nil + } + + var err error + d.session, err = discordgo.New(d.token) + if err != nil { + return err + } + + u, err := d.session.User("@me") + if err != nil { + return err + } + + d.botID = u.ID + d.prefixfn = CheckPrefixFactory(fmt.Sprintf("<@%s> ", d.botID), fmt.Sprintf("<@!%s> ", d.botID), d.prefix) + + d.exit = make(chan struct{}) + d.running = true + + return nil +} + +func (d *discordInput) Stream() (input.Conn, error) { + d.Lock() + defer d.Unlock() + if !d.running { + return nil, errors.New("not running") + } + + //Fire-n-forget close just in case... + d.session.Close() + + conn := newConn(d) + if err := d.session.Open(); err != nil { + return nil, err + } + return conn, nil +} + +func (d *discordInput) Stop() error { + d.Lock() + defer d.Unlock() + + if !d.running { + return nil + } + + close(d.exit) + d.running = false + return nil +} + +func (d *discordInput) String() string { + return "discord" +} + +// CheckPrefixFactory Creates a prefix checking function and stuff. +func CheckPrefixFactory(prefixes ...string) func(string) (string, bool) { + return func(content string) (string, bool) { + for _, prefix := range prefixes { + if strings.HasPrefix(content, prefix) { + return strings.TrimPrefix(content, prefix), true + } + } + return "", false + } +} diff --git a/agent/input/input.go b/agent/input/input.go new file mode 100644 index 00000000..2b1d9aab --- /dev/null +++ b/agent/input/input.go @@ -0,0 +1,55 @@ +// Package input is an interface for bot inputs +package input + +import ( + "github.com/micro/cli" +) + +type EventType string + +const ( + TextEvent EventType = "text" +) + +var ( + // Inputs keyed by name + // Example slack or hipchat + Inputs = map[string]Input{} +) + +// Event is the unit sent and received +type Event struct { + Type EventType + From string + To string + Data []byte + Meta map[string]interface{} +} + +// Input is an interface for sources which +// provide a way to communicate with the bot. +// Slack, HipChat, XMPP, etc. +type Input interface { + // Provide cli flags + Flags() []cli.Flag + // Initialise input using cli context + Init(*cli.Context) error + // Stream events from the input + Stream() (Conn, error) + // Start the input + Start() error + // Stop the input + Stop() error + // name of the input + String() string +} + +// Conn interface provides a way to +// send and receive events. Send and +// Recv both block until succeeding +// or failing. +type Conn interface { + Close() error + Recv(*Event) error + Send(*Event) error +} diff --git a/agent/input/slack/conn.go b/agent/input/slack/conn.go new file mode 100644 index 00000000..ee640922 --- /dev/null +++ b/agent/input/slack/conn.go @@ -0,0 +1,160 @@ +package slack + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/micro/go-micro/agent/input" + "github.com/nlopes/slack" +) + +// Satisfies the input.Conn interface +type slackConn struct { + auth *slack.AuthTestResponse + rtm *slack.RTM + exit chan bool + + sync.Mutex + names map[string]string +} + +func (s *slackConn) run() { + // func retrieves user names and maps to IDs + setNames := func() { + names := make(map[string]string) + users, err := s.rtm.Client.GetUsers() + if err != nil { + return + } + + for _, user := range users { + names[user.ID] = user.Name + } + + s.Lock() + s.names = names + s.Unlock() + } + + setNames() + + t := time.NewTicker(time.Minute) + defer t.Stop() + + for { + select { + case <-s.exit: + return + case <-t.C: + setNames() + } + } +} + +func (s *slackConn) getName(id string) string { + s.Lock() + name := s.names[id] + s.Unlock() + return name +} + +func (s *slackConn) Close() error { + select { + case <-s.exit: + return nil + default: + close(s.exit) + } + return nil +} + +func (s *slackConn) Recv(event *input.Event) error { + if event == nil { + return errors.New("event cannot be nil") + } + + for { + select { + case <-s.exit: + return errors.New("connection closed") + case e := <-s.rtm.IncomingEvents: + switch ev := e.Data.(type) { + case *slack.MessageEvent: + // only accept type message + if ev.Type != "message" { + continue + } + + // only accept DMs or messages to me + switch { + case strings.HasPrefix(ev.Channel, "D"): + case strings.HasPrefix(ev.Text, s.auth.User): + case strings.HasPrefix(ev.Text, fmt.Sprintf("<@%s>", s.auth.UserID)): + default: + continue + } + + // Strip username from text + switch { + case strings.HasPrefix(ev.Text, s.auth.User): + args := strings.Split(ev.Text, " ")[1:] + ev.Text = strings.Join(args, " ") + event.To = s.auth.User + case strings.HasPrefix(ev.Text, fmt.Sprintf("<@%s>", s.auth.UserID)): + args := strings.Split(ev.Text, " ")[1:] + ev.Text = strings.Join(args, " ") + event.To = s.auth.UserID + } + + if event.Meta == nil { + event.Meta = make(map[string]interface{}) + } + + // fill in the blanks + event.From = ev.Channel + ":" + ev.User + event.Type = input.TextEvent + event.Data = []byte(ev.Text) + event.Meta["reply"] = ev + return nil + case *slack.InvalidAuthEvent: + return errors.New("invalid credentials") + } + } + } +} + +func (s *slackConn) Send(event *input.Event) error { + var channel, message, name string + + if len(event.To) == 0 { + return errors.New("require Event.To") + } + + parts := strings.Split(event.To, ":") + + if len(parts) == 2 { + channel = parts[0] + name = s.getName(parts[1]) + // try using reply meta + } else if ev, ok := event.Meta["reply"]; ok { + channel = ev.(*slack.MessageEvent).Channel + name = s.getName(ev.(*slack.MessageEvent).User) + } + + // don't know where to send the message + if len(channel) == 0 { + return errors.New("could not determine who message is to") + } + + if len(name) == 0 || strings.HasPrefix(channel, "D") { + message = string(event.Data) + } else { + message = fmt.Sprintf("@%s: %s", name, string(event.Data)) + } + + s.rtm.SendMessage(s.rtm.NewOutgoingMessage(message, channel)) + return nil +} diff --git a/agent/input/slack/slack.go b/agent/input/slack/slack.go new file mode 100644 index 00000000..9e67b2ed --- /dev/null +++ b/agent/input/slack/slack.go @@ -0,0 +1,147 @@ +package slack + +import ( + "errors" + "sync" + + "github.com/micro/cli" + "github.com/micro/go-micro/agent/input" + "github.com/nlopes/slack" +) + +type slackInput struct { + debug bool + token string + + sync.Mutex + running bool + exit chan bool + + api *slack.Client +} + +func init() { + input.Inputs["slack"] = NewInput() +} + +func (p *slackInput) Flags() []cli.Flag { + return []cli.Flag{ + cli.BoolFlag{ + Name: "slack_debug", + Usage: "Slack debug output", + EnvVar: "MICRO_SLACK_DEBUG", + }, + cli.StringFlag{ + Name: "slack_token", + Usage: "Slack token", + EnvVar: "MICRO_SLACK_TOKEN", + }, + } +} + +func (p *slackInput) Init(ctx *cli.Context) error { + debug := ctx.Bool("slack_debug") + token := ctx.String("slack_token") + + if len(token) == 0 { + return errors.New("missing slack token") + } + + p.debug = debug + p.token = token + + return nil +} + +func (p *slackInput) Stream() (input.Conn, error) { + p.Lock() + defer p.Unlock() + + if !p.running { + return nil, errors.New("not running") + } + + // test auth + auth, err := p.api.AuthTest() + if err != nil { + return nil, err + } + + rtm := p.api.NewRTM() + exit := make(chan bool) + + go rtm.ManageConnection() + + go func() { + select { + case <-p.exit: + select { + case <-exit: + return + default: + close(exit) + } + case <-exit: + } + + rtm.Disconnect() + }() + + conn := &slackConn{ + auth: auth, + rtm: rtm, + exit: exit, + names: make(map[string]string), + } + + go conn.run() + + return conn, nil +} + +func (p *slackInput) Start() error { + if len(p.token) == 0 { + return errors.New("missing slack token") + } + + p.Lock() + defer p.Unlock() + + if p.running { + return nil + } + + api := slack.New(p.token, slack.OptionDebug(p.debug)) + + // test auth + _, err := api.AuthTest() + if err != nil { + return err + } + + p.api = api + p.exit = make(chan bool) + p.running = true + return nil +} + +func (p *slackInput) Stop() error { + p.Lock() + defer p.Unlock() + + if !p.running { + return nil + } + + close(p.exit) + p.running = false + return nil +} + +func (p *slackInput) String() string { + return "slack" +} + +func NewInput() input.Input { + return &slackInput{} +} diff --git a/agent/input/telegram/README.md b/agent/input/telegram/README.md new file mode 100644 index 00000000..c68cdeca --- /dev/null +++ b/agent/input/telegram/README.md @@ -0,0 +1,18 @@ +# Telegram Messenger input for micro bot +[Telegram](https://telegram.org) support for micro bot based on [telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api). + +## Options +### --telegram_token (required) + +Sets bot's token for interacting with API. + +Head over to Telegram's [API documentation](https://core.telegram.org/bots/api) +to learn how to create bots and how the API works. + +### --telegram_debug + +Sets the debug flag to make the bot's output verbose. + +### --telegram_whitelist + +Sets a list of comma-separated nicknames (without @ symbol in the beginning) for interacting with bot. Only these users can use the bot. diff --git a/agent/input/telegram/conn.go b/agent/input/telegram/conn.go new file mode 100644 index 00000000..99311f4c --- /dev/null +++ b/agent/input/telegram/conn.go @@ -0,0 +1,115 @@ +package telegram + +import ( + "errors" + "strings" + "sync" + + "github.com/forestgiant/sliceutil" + "github.com/micro/go-micro/agent/input" + "github.com/micro/go-log" + "gopkg.in/telegram-bot-api.v4" +) + +type telegramConn struct { + input *telegramInput + + recv <-chan tgbotapi.Update + exit chan bool + + syncCond *sync.Cond + mutex sync.Mutex +} + +func newConn(input *telegramInput) (*telegramConn, error) { + conn := &telegramConn{ + input: input, + } + + conn.syncCond = sync.NewCond(&conn.mutex) + + go conn.run() + + return conn, nil +} + +func (tc *telegramConn) run() { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates, err := tc.input.api.GetUpdatesChan(u) + if err != nil { + return + } + + tc.recv = updates + tc.syncCond.Signal() + + for { + select { + case <-tc.exit: + return + } + } +} + +func (tc *telegramConn) Close() error { + return nil +} + +func (tc *telegramConn) Recv(event *input.Event) error { + if event == nil { + return errors.New("event cannot be nil") + } + + for { + if tc.recv == nil { + tc.mutex.Lock() + tc.syncCond.Wait() + } + + update := <-tc.recv + + if update.Message == nil || (len(tc.input.whitelist) > 0 && !sliceutil.Contains(tc.input.whitelist, update.Message.From.UserName)) { + continue + } + + if event.Meta == nil { + event.Meta = make(map[string]interface{}) + } + + event.Type = input.TextEvent + event.From = update.Message.From.UserName + event.To = tc.input.api.Self.UserName + event.Data = []byte(update.Message.Text) + event.Meta["chatId"] = update.Message.Chat.ID + event.Meta["chatType"] = update.Message.Chat.Type + event.Meta["messageId"] = update.Message.MessageID + + return nil + } +} + +func (tc *telegramConn) Send(event *input.Event) error { + messageText := strings.TrimSpace(string(event.Data)) + + chatId := event.Meta["chatId"].(int64) + chatType := ChatType(event.Meta["chatType"].(string)) + + msgConfig := tgbotapi.NewMessage(chatId, messageText) + msgConfig.ParseMode = tgbotapi.ModeHTML + + if sliceutil.Contains([]ChatType{Group, Supergroup}, chatType) { + msgConfig.ReplyToMessageID = event.Meta["messageId"].(int) + } + + _, err := tc.input.api.Send(msgConfig) + + if err != nil { + // probably it could be because of nested HTML tags -- telegram doesn't allow nested tags + log.Log("[telegram][Send] error:", err) + msgConfig.Text = "This bot couldn't send the response (Internal error)" + tc.input.api.Send(msgConfig) + } + + return nil +} diff --git a/agent/input/telegram/telegram.go b/agent/input/telegram/telegram.go new file mode 100644 index 00000000..87566e4e --- /dev/null +++ b/agent/input/telegram/telegram.go @@ -0,0 +1,101 @@ +package telegram + +import ( + "errors" + "strings" + "sync" + + "github.com/micro/cli" + "github.com/micro/go-micro/agent/input" + "gopkg.in/telegram-bot-api.v4" +) + +type telegramInput struct { + sync.Mutex + + debug bool + token string + whitelist []string + + api *tgbotapi.BotAPI +} + +type ChatType string + +const ( + Private ChatType = "private" + Group ChatType = "group" + Supergroup ChatType = "supergroup" +) + +func init() { + input.Inputs["telegram"] = &telegramInput{} +} + +func (ti *telegramInput) Flags() []cli.Flag { + return []cli.Flag{ + cli.BoolFlag{ + Name: "telegram_debug", + EnvVar: "MICRO_TELEGRAM_DEBUG", + Usage: "Telegram debug output", + }, + cli.StringFlag{ + Name: "telegram_token", + EnvVar: "MICRO_TELEGRAM_TOKEN", + Usage: "Telegram token", + }, + cli.StringFlag{ + Name: "telegram_whitelist", + EnvVar: "MICRO_TELEGRAM_WHITELIST", + Usage: "Telegram bot's users (comma-separated values)", + }, + } +} + +func (ti *telegramInput) Init(ctx *cli.Context) error { + ti.debug = ctx.Bool("telegram_debug") + ti.token = ctx.String("telegram_token") + + whitelist := ctx.String("telegram_whitelist") + + if whitelist != "" { + ti.whitelist = strings.Split(whitelist, ",") + } + + if len(ti.token) == 0 { + return errors.New("missing telegram token") + } + + return nil +} + +func (ti *telegramInput) Stream() (input.Conn, error) { + ti.Lock() + defer ti.Unlock() + + return newConn(ti) +} + +func (ti *telegramInput) Start() error { + ti.Lock() + defer ti.Unlock() + + api, err := tgbotapi.NewBotAPI(ti.token) + if err != nil { + return err + } + + ti.api = api + + api.Debug = ti.debug + + return nil +} + +func (ti *telegramInput) Stop() error { + return nil +} + +func (p *telegramInput) String() string { + return "telegram" +} diff --git a/agent/proto/bot.micro.go b/agent/proto/bot.micro.go new file mode 100644 index 00000000..2bac4842 --- /dev/null +++ b/agent/proto/bot.micro.go @@ -0,0 +1,118 @@ +// Code generated by protoc-gen-micro. DO NOT EDIT. +// source: github.com/micro/go-bot/proto/bot.proto + +/* +Package go_micro_bot is a generated protocol buffer package. + +It is generated from these files: + github.com/micro/go-bot/proto/bot.proto + +It has these top-level messages: + HelpRequest + HelpResponse + ExecRequest + ExecResponse +*/ +package go_micro_bot + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +import ( + client "github.com/micro/go-micro/client" + server "github.com/micro/go-micro/server" + context "context" +) + +// 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.ProtoPackageIsVersion2 // please upgrade the proto package + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ client.Option +var _ server.Option + +// Client API for Command service + +type CommandService interface { + Help(ctx context.Context, in *HelpRequest, opts ...client.CallOption) (*HelpResponse, error) + Exec(ctx context.Context, in *ExecRequest, opts ...client.CallOption) (*ExecResponse, error) +} + +type commandService struct { + c client.Client + name string +} + +func NewCommandService(name string, c client.Client) CommandService { + if c == nil { + c = client.NewClient() + } + if len(name) == 0 { + name = "go.micro.bot" + } + return &commandService{ + c: c, + name: name, + } +} + +func (c *commandService) Help(ctx context.Context, in *HelpRequest, opts ...client.CallOption) (*HelpResponse, error) { + req := c.c.NewRequest(c.name, "Command.Help", in) + out := new(HelpResponse) + err := c.c.Call(ctx, req, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *commandService) Exec(ctx context.Context, in *ExecRequest, opts ...client.CallOption) (*ExecResponse, error) { + req := c.c.NewRequest(c.name, "Command.Exec", in) + out := new(ExecResponse) + err := c.c.Call(ctx, req, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// Server API for Command service + +type CommandHandler interface { + Help(context.Context, *HelpRequest, *HelpResponse) error + Exec(context.Context, *ExecRequest, *ExecResponse) error +} + +func RegisterCommandHandler(s server.Server, hdlr CommandHandler, opts ...server.HandlerOption) error { + type _command interface { + Help(ctx context.Context, in *HelpRequest, out *HelpResponse) error + Exec(ctx context.Context, in *ExecRequest, out *ExecResponse) error + } + type Command struct { + _command + } + h := &commandHandler{hdlr} + return s.Handle(s.NewHandler(&Command{h}, opts...)) +} + +type commandHandler struct { + CommandHandler +} + +func (h *commandHandler) Help(ctx context.Context, in *HelpRequest, out *HelpResponse) error { + return h.CommandHandler.Help(ctx, in, out) +} + +func (h *commandHandler) Exec(ctx context.Context, in *ExecRequest, out *ExecResponse) error { + return h.CommandHandler.Exec(ctx, in, out) +} diff --git a/agent/proto/bot.pb.go b/agent/proto/bot.pb.go new file mode 100644 index 00000000..f36d9f4a --- /dev/null +++ b/agent/proto/bot.pb.go @@ -0,0 +1,210 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: github.com/micro/go-bot/proto/bot.proto + +package go_micro_bot + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +// 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.ProtoPackageIsVersion2 // please upgrade the proto package + +type HelpRequest struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *HelpRequest) Reset() { *m = HelpRequest{} } +func (m *HelpRequest) String() string { return proto.CompactTextString(m) } +func (*HelpRequest) ProtoMessage() {} +func (*HelpRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_bot_654832eab83ed4b5, []int{0} +} +func (m *HelpRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_HelpRequest.Unmarshal(m, b) +} +func (m *HelpRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_HelpRequest.Marshal(b, m, deterministic) +} +func (dst *HelpRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_HelpRequest.Merge(dst, src) +} +func (m *HelpRequest) XXX_Size() int { + return xxx_messageInfo_HelpRequest.Size(m) +} +func (m *HelpRequest) XXX_DiscardUnknown() { + xxx_messageInfo_HelpRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_HelpRequest proto.InternalMessageInfo + +type HelpResponse struct { + Usage string `protobuf:"bytes,1,opt,name=usage,proto3" json:"usage,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *HelpResponse) Reset() { *m = HelpResponse{} } +func (m *HelpResponse) String() string { return proto.CompactTextString(m) } +func (*HelpResponse) ProtoMessage() {} +func (*HelpResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_bot_654832eab83ed4b5, []int{1} +} +func (m *HelpResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_HelpResponse.Unmarshal(m, b) +} +func (m *HelpResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_HelpResponse.Marshal(b, m, deterministic) +} +func (dst *HelpResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_HelpResponse.Merge(dst, src) +} +func (m *HelpResponse) XXX_Size() int { + return xxx_messageInfo_HelpResponse.Size(m) +} +func (m *HelpResponse) XXX_DiscardUnknown() { + xxx_messageInfo_HelpResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_HelpResponse proto.InternalMessageInfo + +func (m *HelpResponse) GetUsage() string { + if m != nil { + return m.Usage + } + return "" +} + +func (m *HelpResponse) GetDescription() string { + if m != nil { + return m.Description + } + return "" +} + +type ExecRequest struct { + Args []string `protobuf:"bytes,1,rep,name=args,proto3" json:"args,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ExecRequest) Reset() { *m = ExecRequest{} } +func (m *ExecRequest) String() string { return proto.CompactTextString(m) } +func (*ExecRequest) ProtoMessage() {} +func (*ExecRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_bot_654832eab83ed4b5, []int{2} +} +func (m *ExecRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ExecRequest.Unmarshal(m, b) +} +func (m *ExecRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ExecRequest.Marshal(b, m, deterministic) +} +func (dst *ExecRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_ExecRequest.Merge(dst, src) +} +func (m *ExecRequest) XXX_Size() int { + return xxx_messageInfo_ExecRequest.Size(m) +} +func (m *ExecRequest) XXX_DiscardUnknown() { + xxx_messageInfo_ExecRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_ExecRequest proto.InternalMessageInfo + +func (m *ExecRequest) GetArgs() []string { + if m != nil { + return m.Args + } + return nil +} + +type ExecResponse struct { + Result []byte `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ExecResponse) Reset() { *m = ExecResponse{} } +func (m *ExecResponse) String() string { return proto.CompactTextString(m) } +func (*ExecResponse) ProtoMessage() {} +func (*ExecResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_bot_654832eab83ed4b5, []int{3} +} +func (m *ExecResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ExecResponse.Unmarshal(m, b) +} +func (m *ExecResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ExecResponse.Marshal(b, m, deterministic) +} +func (dst *ExecResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_ExecResponse.Merge(dst, src) +} +func (m *ExecResponse) XXX_Size() int { + return xxx_messageInfo_ExecResponse.Size(m) +} +func (m *ExecResponse) XXX_DiscardUnknown() { + xxx_messageInfo_ExecResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_ExecResponse proto.InternalMessageInfo + +func (m *ExecResponse) GetResult() []byte { + if m != nil { + return m.Result + } + return nil +} + +func (m *ExecResponse) GetError() string { + if m != nil { + return m.Error + } + return "" +} + +func init() { + proto.RegisterType((*HelpRequest)(nil), "go.micro.bot.HelpRequest") + proto.RegisterType((*HelpResponse)(nil), "go.micro.bot.HelpResponse") + proto.RegisterType((*ExecRequest)(nil), "go.micro.bot.ExecRequest") + proto.RegisterType((*ExecResponse)(nil), "go.micro.bot.ExecResponse") +} + +func init() { + proto.RegisterFile("github.com/micro/go-bot/proto/bot.proto", fileDescriptor_bot_654832eab83ed4b5) +} + +var fileDescriptor_bot_654832eab83ed4b5 = []byte{ + // 246 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x90, 0x41, 0x4b, 0xc4, 0x30, + 0x10, 0x85, 0xb7, 0xba, 0xae, 0xec, 0xb4, 0x5e, 0x82, 0x48, 0xdd, 0x53, 0xcd, 0xc5, 0xbd, 0x98, + 0x82, 0x5e, 0x05, 0x0f, 0xa2, 0x78, 0xee, 0x3f, 0x68, 0xba, 0x43, 0x2c, 0x6c, 0x3b, 0x35, 0x99, + 0x82, 0xff, 0xc1, 0x3f, 0x2d, 0x4d, 0x72, 0x08, 0xcb, 0xde, 0xe6, 0x65, 0x86, 0xf7, 0xbe, 0x17, + 0x78, 0x34, 0x3d, 0x7f, 0xcf, 0x5a, 0x75, 0x34, 0xd4, 0x43, 0xdf, 0x59, 0xaa, 0x0d, 0x3d, 0x69, + 0xe2, 0x7a, 0xb2, 0xc4, 0x54, 0x6b, 0x62, 0xe5, 0x27, 0x51, 0x18, 0x52, 0xfe, 0x40, 0x69, 0x62, + 0x79, 0x03, 0xf9, 0x17, 0x1e, 0xa7, 0x06, 0x7f, 0x66, 0x74, 0x2c, 0x3f, 0xa1, 0x08, 0xd2, 0x4d, + 0x34, 0x3a, 0x14, 0xb7, 0x70, 0x35, 0xbb, 0xd6, 0x60, 0x99, 0x55, 0xd9, 0x7e, 0xdb, 0x04, 0x21, + 0x2a, 0xc8, 0x0f, 0xe8, 0x3a, 0xdb, 0x4f, 0xdc, 0xd3, 0x58, 0x5e, 0xf8, 0x5d, 0xfa, 0x24, 0x1f, + 0x20, 0xff, 0xf8, 0xc5, 0x2e, 0xda, 0x0a, 0x01, 0xeb, 0xd6, 0x1a, 0x57, 0x66, 0xd5, 0xe5, 0x7e, + 0xdb, 0xf8, 0x59, 0xbe, 0x42, 0x11, 0x4e, 0x62, 0xd4, 0x1d, 0x6c, 0x2c, 0xba, 0xf9, 0xc8, 0x3e, + 0xab, 0x68, 0xa2, 0x5a, 0x10, 0xd0, 0x5a, 0xb2, 0x31, 0x26, 0x88, 0xe7, 0xbf, 0x0c, 0xae, 0xdf, + 0x69, 0x18, 0xda, 0xf1, 0x20, 0xde, 0x60, 0xbd, 0x40, 0x8b, 0x7b, 0x95, 0x56, 0x53, 0x49, 0xaf, + 0xdd, 0xee, 0xdc, 0x2a, 0x04, 0xcb, 0xd5, 0x62, 0xb0, 0xa0, 0x9c, 0x1a, 0x24, 0x0d, 0x4e, 0x0d, + 0x52, 0x72, 0xb9, 0xd2, 0x1b, 0xff, 0xb5, 0x2f, 0xff, 0x01, 0x00, 0x00, 0xff, 0xff, 0xcb, 0x77, + 0xdf, 0x28, 0x85, 0x01, 0x00, 0x00, +} diff --git a/agent/proto/bot.proto b/agent/proto/bot.proto new file mode 100644 index 00000000..0f808615 --- /dev/null +++ b/agent/proto/bot.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package go.micro.bot; + +service Command { + rpc Help(HelpRequest) returns (HelpResponse) {}; + rpc Exec(ExecRequest) returns (ExecResponse) {}; +} + +message HelpRequest { +} + +message HelpResponse { + string usage = 1; + string description = 2; +} + +message ExecRequest { + repeated string args = 1; +} + +message ExecResponse { + bytes result = 1; + string error = 2; +}