diff --git a/advertisement.go b/advertisement.go new file mode 100644 index 0000000..75b5f45 --- /dev/null +++ b/advertisement.go @@ -0,0 +1,63 @@ +package slackbot + +import ( + "fmt" + "slices" + "strings" + + "github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1" +) + +type commandAdvertiser interface { + AdvertiseCommands(commands []chat1.UserBotCommandInput) error +} + +func (b *Bot) AddAdvertisements(commands ...chat1.UserBotCommandInput) { + b.advertisements = append(b.advertisements, commands...) +} + +func (b *Bot) AdvertisedCommands() []chat1.UserBotCommandInput { + commands := []chat1.UserBotCommandInput{{ + Name: "help", + Description: "Show available commands", + Usage: fmt.Sprintf("!%s help", b.name), + ExtendedDescription: b.helpExtendedDescription(), + }} + + for _, trigger := range b.triggers() { + command := b.commands[trigger] + commands = append(commands, chat1.UserBotCommandInput{ + Name: trigger, + Description: command.Description(), + Usage: fmt.Sprintf("!%s %s", b.name, trigger), + }) + } + + extras := slices.Clone(b.advertisements) + slices.SortFunc(extras, func(a, b chat1.UserBotCommandInput) int { + return strings.Compare(a.Name, b.Name) + }) + commands = append(commands, extras...) + + return commands +} + +func (b *Bot) advertiseCommands() error { + advertiser, ok := b.backend.(commandAdvertiser) + if !ok { + return nil + } + return advertiser.AdvertiseCommands(b.AdvertisedCommands()) +} + +func (b *Bot) helpExtendedDescription() *chat1.UserBotExtendedDescription { + help := strings.TrimSpace(b.resolvedHelp()) + if help == "" { + return nil + } + return &chat1.UserBotExtendedDescription{ + Title: fmt.Sprintf("%s help", b.name), + DesktopBody: help, + MobileBody: help, + } +} diff --git a/bot.go b/bot.go index 6d93452..c73a3aa 100644 --- a/bot.go +++ b/bot.go @@ -11,6 +11,8 @@ import ( "sort" "strings" "text/tabwriter" + + "github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1" ) type BotCommandRunner interface { @@ -30,6 +32,7 @@ type Bot struct { label string config Config commands map[string]Command + advertisements []chat1.UserBotCommandInput defaultCommand Command } @@ -138,12 +141,15 @@ func (b *Bot) run(args []string, command Command, channel string) { } } -func (b *Bot) sendHelpMessage(channel string) { - help := b.help - if help == "" { - help = b.HelpMessage() +func (b *Bot) resolvedHelp() string { + if b.help != "" { + return b.help } - b.backend.SendMessage(help, channel) + return b.HelpMessage() +} + +func (b *Bot) sendHelpMessage(channel string) { + b.backend.SendMessage(b.resolvedHelp(), channel) } func (b *Bot) SendMessage(text string, channel string) { @@ -151,6 +157,9 @@ func (b *Bot) SendMessage(text string, channel string) { } func (b *Bot) Listen() { + if err := b.advertiseCommands(); err != nil { + log.Printf("Error advertising commands: %s", err) + } b.backend.Listen(b) } diff --git a/bot_test.go b/bot_test.go index 2b953f8..b8f10c2 100644 --- a/bot_test.go +++ b/bot_test.go @@ -5,13 +5,14 @@ package slackbot import ( "testing" + + "github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1" + "github.com/stretchr/testify/require" ) func TestHelp(t *testing.T) { bot, err := NewTestBot() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) bot.AddCommand("date", NewExecCommand("/bin/date", nil, true, "Show the current date", &config{})) bot.AddCommand("utc", NewExecCommand("/bin/date", []string{"-u"}, true, "Show the current date (utc)", &config{})) msg := bot.HelpMessage() @@ -27,3 +28,40 @@ func TestParseInput(t *testing.T) { t.Fatal("Invalid parse") } } + +func TestAdvertisedCommands(t *testing.T) { + bot, err := NewTestBot() + require.NoError(t, err) + bot.AddCommand("date", NewExecCommand("/bin/date", nil, true, "Show the current date", &config{})) + bot.SetHelp("help body") + bot.AddAdvertisements(chat1.UserBotCommandInput{ + Name: "build", + Description: "Build things", + Usage: "!testbot build ", + }) + + commands := bot.AdvertisedCommands() + if len(commands) != 3 { + t.Fatalf("expected 3 advertised commands, got %d", len(commands)) + } + if commands[0].Name != "help" { + t.Fatalf("expected help command first, got %q", commands[0].Name) + } + if commands[0].ExtendedDescription == nil || commands[0].ExtendedDescription.DesktopBody != "help body" { + t.Fatalf("unexpected help extended description: %+v", commands[0].ExtendedDescription) + } + if commands[1] != (chat1.UserBotCommandInput{ + Name: "date", + Description: "Show the current date", + Usage: "!testbot date", + }) { + t.Fatalf("unexpected builtin command: %+v", commands[1]) + } + if commands[2] != (chat1.UserBotCommandInput{ + Name: "build", + Description: "Build things", + Usage: "!testbot build ", + }) { + t.Fatalf("unexpected extra command: %+v", commands[2]) + } +} diff --git a/kbchat.go b/kbchat.go index 2d1ea71..09daaa5 100644 --- a/kbchat.go +++ b/kbchat.go @@ -42,6 +42,21 @@ func (b *KeybaseChatBotBackend) SendMessage(text string, convID string) { } } +func (b *KeybaseChatBotBackend) AdvertiseCommands(commands []chat1.UserBotCommandInput) error { + if b.convID == "" { + return nil + } + _, err := b.kbc.AdvertiseCommands(kbchat.Advertisement{ + Alias: b.name, + Advertisements: []chat1.AdvertiseCommandAPIParam{{ + Typ: "conv", + Commands: commands, + ConvID: b.convID, + }}, + }) + return err +} + func (b *KeybaseChatBotBackend) Listen(runner BotCommandRunner) { sub, err := b.kbc.ListenForNewTextMessages() if err != nil { diff --git a/keybot/keybot.go b/keybot/keybot.go index ad072b1..fb2f74d 100644 --- a/keybot/keybot.go +++ b/keybot/keybot.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1" "github.com/keybase/slackbot" "github.com/keybase/slackbot/cli" "github.com/keybase/slackbot/launchd" @@ -311,3 +312,18 @@ func (k *keybot) Help(bot *slackbot.Bot) string { } return out } + +func (k *keybot) Advertisements(bot *slackbot.Bot) []chat1.UserBotCommandInput { + prefix := "!" + bot.Name() + return []chat1.UserBotCommandInput{ + {Name: "build", Description: "Build darwin, mobile, android, or ios artifacts", Usage: prefix + " build [flags]"}, + {Name: "cancel", Description: "Cancel a launchd job by label", Usage: prefix + " cancel