bawt

package module
v0.0.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 2, 2019 License: MIT Imports: 19 Imported by: 0

README

Build Status Release GoDoc Go Report Card License Open Issues Open PRs

Bawt

Bawt is a chatops framework rather than a bot in itself, hence, Bawt is distributed as a package and all it takes to start is one file and one function.

Our goal is that bawt is always easy to start, easy to run, easy to enhance

Easy to start. The single biggest turn off I had when trying out a new project was the amount of time it took me to get started with something meaningful. The plugin footprint is intentionally light yet extensive and why Bawt's core can be started with just two files. We aim to strike a rare balance of extensibility and simplicity.

Easy to run. Bawt is updated via modules and follows Semantic Versioning (SemVer) so you'll always know what sort of changes await you. Bawt's core code is abstracted into the Messaging API so even when Slack breaks their API's (and they will) you will never notice as a plugin developer.

Easy to enhance. We want Bawt's code to make sense not just to core developers but to plugin devs as well. That's why Bawt's core is both verbose and descriptive. There's no buried functionality, what you see is what you get.

Local build and install

With Modules
go get github.com/gopherworks/bawt
Without Modules
go get github.com/gopherworks/bawt
cd $GOPATH/src/github.com/gopherworks/bawt/example-bot
go install -v && $GOPATH/bin/example-bot

There's a Dockerfile, example bot, and configuration in the example-bot directory.

Writing your own plugin

Example code to handle deployments:

// listenDeploy was hooked into a plugin elsewhere..
func listenDeploy() {
	keywords := []string{"project1", "project2", "project3"}
	bot.Listen(&bawt.Listener{
		Matches:        regexp.MustCompile("(can you|could you|please|plz|c'mon|icanhaz) deploy (" + strings.Join(keywords, "|") + ") (with|using)( revision| commit)? `?([a-z0-9]{4,42})`?"),
		MentionsMeOnly: true,
		MessageHandlerFunc: func(listen *bawt.Listener, msg *bawt.Message) {

			projectName := msg.Match[2]
			revision := msg.Match[5]

			go func() {
				go msg.AddReaction("work_hard")
				defer msg.RemoveReaction("work_hard")

				// Do the deployment with projectName and revision...

			}()
		},
	})
}

Documentation

Overview

Package bawt is a Slack bot framework written in Go

Index

Constants

View Source
const GroupEmptyList = "[\"\"]"

GroupEmptyList is used to declare an empty list

View Source
const GroupEmptyObject = "{}"

GroupEmptyObject is used to declare an empty JSON object

View Source
const GroupMembers = "Members"

GroupMembers is the name of the Bolt DB key for members of groups

View Source
const GroupSlackGroup = "SlackGroup"

GroupSlackGroup is the name of the Bolt DB key for an Internal Groups corresponding Slack Group (if any)

View Source
const Groups = "groups"

Groups is the name of the Bolt DB bucket for groups

View Source
const ReactionAdded = reaction(2)

ReactionAdded is used as the `Type` field of `ReactionEvent` (which you can register with `Reply.OnReaction()`)

View Source
const ReactionRemoved = reaction(1)

ReactionRemoved is the flipside of `ReactionAdded`.

Variables

View Source
var Version string

Version is the software version

Functions

func AfterNextWeekdayTime

func AfterNextWeekdayTime(from time.Time, w time.Weekday, hour, min int) <-chan time.Time

AfterNextWeekdayTime from a given Time and a Weekday + hour + minute returns a channel that waits for the duration to elapse and then sends the current time on the returned channel.

func Format

func Format(s string, v ...interface{}) string

Format conditionally formats using fmt.Sprintf if there is more than one argument, otherwise returns the first parameter uninterpreted.

func NextWeekdayTime

func NextWeekdayTime(from time.Time, w time.Weekday, hour, min int) (time.Time, time.Duration)

NextWeekdayTime from a given Time and a Weekday + hour + minute returns the next WeekdayTime and the elapsed time between the two instants as an int64 nanosecond count.

func NormalizeID added in v0.0.4

func NormalizeID(id string) string

NormalizeID normalizes slack user and channel ID's

func RandomString

func RandomString(category string) string

RandomString returns a random string from the array stored at category

func RegisterPlugin

func RegisterPlugin(plugin Plugin)

RegisterPlugin adds the provided Plugin to the list of registered plugins

func RegisterStringList

func RegisterStringList(category string, list []string)

RegisterStringList takes an array of strings and stores them in a category

Types

type Bot

type Bot struct {
	Config       SlackConfig `json:"Config"`
	Logging      Logging     `json:"Logging"`
	GlobalAdmins []string    `json:"GlobalAdmins"`

	// Slack connectivity
	Slack *slack.Client

	Users    map[string]slack.User
	Groups   []InternalGroup
	Channels map[string]Channel

	Myself slack.UserDetails

	// Storage
	DB *bolt.DB

	// Inter-plugins communications. Use topics like
	// "pluginName:eventType[:someOtherThing]"
	PubSub *pubsub.PubSub

	// Other features
	WebServer WebServer
	Mood      Mood
	// contains filtered or unexported fields
}

Bot connects Bawt's configuration and API

func New

func New(configFile string) *Bot

New returns a new bot instance, initialized with the provided config file. If an empty string is provided as the config file path, bawt searches the working directory and $HOME/.bawt/ for a file called config.json|toml|yaml instead

func (*Bot) Disconnect

func (bot *Bot) Disconnect()

Disconnect the websocket.

func (*Bot) GetChannelByName

func (bot *Bot) GetChannelByName(name string) *Channel

GetChannelByName returns a *slack.Channel by Name

func (*Bot) GetDBKey

func (bot *Bot) GetDBKey(key string, v interface{}) error

GetDBKey retrieves a `key` from persistent storage and JSON unmarshales it into `v`. We need to `Update` otherwise CreateBucketIfNotExists cannot create a bucket and returns an error immediately.

func (*Bot) GetGroup added in v0.0.4

func (bot *Bot) GetGroup(name string) *InternalGroup

GetGroup retrieves a group from BoltDB

func (*Bot) GetIMChannelWith

func (bot *Bot) GetIMChannelWith(user *slack.User) *Channel

GetIMChannelWith returns the channel used to communicate with the specified slack user

func (*Bot) GetUser

func (bot *Bot) GetUser(find string) *slack.User

GetUser returns a *slack.User by ID, Name, RealName or Email

func (*Bot) Listen

func (bot *Bot) Listen(listen *Listener) error

Listen registers a listener for messages and events. There are two main handling functions on a Listener: MessageHandlerFunc and EventHandlerFunc. MessageHandlerFunc is filtered by a bunch of other properties of the Listener, whereas EventHandlerFunc will receive all events unfiltered, but with *bawt.Message instead of a raw *slack.MessageEvent (it's in there anyway), which adds a bunch of useful methods to it.

Explore the Listener for more details.

func (*Bot) ListenReaction

func (bot *Bot) ListenReaction(item string, reactListen *ReactionListener)

ListenReaction will dispatch the listener with matching incoming reactions. `item` can be a timestamp or a file ID.

func (*Bot) Listeners added in v0.0.4

func (bot *Bot) Listeners() []*Listener

Listeners returns an array of active listeners

func (*Bot) LoadConfig

func (bot *Bot) LoadConfig(cfg interface{}) error

LoadConfig will load configuration from a file or environment variables and populate it into the Bot struct

func (*Bot) OpenIMChannelWith

func (bot *Bot) OpenIMChannelWith(user *slack.User) *Channel

OpenIMChannelWith opens a conversation with the given slack User

func (*Bot) PutDBKey

func (bot *Bot) PutDBKey(key string, v interface{}) error

PutDBKey sets a key to the specified value in the persistent storage it JSON marshals the value before storing it.

func (*Bot) Run

func (bot *Bot) Run()

Run loads the config, turns on logging, writes the PID, and loads the plugins.

func (*Bot) SendOutgoingMessage

func (bot *Bot) SendOutgoingMessage(text string, to string) *Reply

SendOutgoingMessage schedules the message for departure and returns a Reply which can be listened on. See type `Reply`.

func (*Bot) SendPrivateMessage

func (bot *Bot) SendPrivateMessage(username, message string) *Reply

SendPrivateMessage sends a message to a user

func (*Bot) SendToChannel

func (bot *Bot) SendToChannel(channelName string, message string) *Reply

SendToChannel sends a message to a given channel

func (*Bot) UploadFile added in v0.0.4

func (bot *Bot) UploadFile(p FileUploadParameters) *ReplyWithFile

UploadFile can be used to send a message with a file

func (*Bot) WithMood

func (bot *Bot) WithMood(happy, hyper string) string

WithMood returns a different response depending on the mood

type Channel

type Channel struct {
	ID       string
	Created  time.Time
	IsOpen   bool
	LastRead string
	Name     string
	Creator  string

	// Three mutually exclusives
	IsChannel bool
	IsGroup   bool
	IsIM      bool

	// Only with `IsChannel`
	IsGeneral bool
	Members   []string

	// Only for `IsGroup` (?)
	IsMember   bool
	IsArchived bool

	// Only for `IsIM`
	User          string
	IsUserDeleted bool

	// Only for `IsChannel || IsGroup`
	Topic   slack.Topic
	Purpose slack.Purpose
}

Channel is an abstraction of the Slack channels, with merged and distinguished data from IMs, Groups and Channels.

func ChannelFromSlackChannel

func ChannelFromSlackChannel(channel slack.Channel) Channel

ChannelFromSlackChannel converts a slack channel to a Channel Struct

func ChannelFromSlackGroup

func ChannelFromSlackGroup(group slack.Group) Channel

ChannelFromSlackGroup converts a slack group to a Channel Struct

func ChannelFromSlackIM

func ChannelFromSlackIM(im slack.IM) Channel

ChannelFromSlackIM converts a slack IM to a Channel Struct

type Command added in v0.0.4

type Command struct {
	Usage    string
	HelpText string
}

Command is a command the bot is capable of understanding

type FileUploadParameters added in v0.0.4

type FileUploadParameters slack.FileUploadParameters

FileUploadParameters are all the parameters needed to upload a file

type InternalGroup added in v0.0.4

type InternalGroup struct {
	Name       string
	SlackGroup slack.UserGroup `json:",omitempty"`
	Members    []string
}

InternalGroup represents a group internal to the framework

func (*InternalGroup) AddMember added in v0.0.4

func (g *InternalGroup) AddMember(db *bolt.DB, user string) error

AddMember appends a user to the member list

func (InternalGroup) FindDuplicate added in v0.0.4

func (g InternalGroup) FindDuplicate(db *bolt.DB, user string) bool

FindDuplicate returns true if it finds a duplicate

func (*InternalGroup) Get added in v0.0.4

func (g *InternalGroup) Get(db *bolt.DB) error

Get fetches the data from the database and unmarshals it into the struct

func (InternalGroup) IsUserMember added in v0.0.4

func (g InternalGroup) IsUserMember(db *bolt.DB, user string) (bool, error)

IsUserMember looks for a user ID that is a member of the given group

func (*InternalGroup) Put added in v0.0.4

func (g *InternalGroup) Put(db *bolt.DB) error

Put pulls information out of the struct and stores it in the database

func (*InternalGroup) RemoveMember added in v0.0.4

func (g *InternalGroup) RemoveMember(db *bolt.DB, user string) error

RemoveMember removes a user from the members list

type Listener

type Listener struct {
	// Name of the app. Used during app listing.
	Name string

	// Description of the app. Used during app listing.
	Description string

	// Slug is a short code used in the help menu
	Slug string

	// Commands are the help documentation for commands
	Commands []Command

	// ListenUntil sets an absolute date at which this Listener
	// expires and stops listening.  ListenUntil and ListenDuration
	// are optional and mutually exclusive.
	ListenUntil time.Time

	// ListenDuration sets a timeout Duration, after which this
	// Listener stops listening and is garbage collected.  A call
	// to `ResetTimeout()` restarts the listening period for another
	// `ListenDuration`.
	ListenDuration time.Duration

	// FromUser filters out incoming messages that are not with
	// `*User` (publicly or privately)
	FromUser *slack.User

	// FromChannel filters messages that are sent to a different room than
	// `Room`. This can be mixed and matched with `FromUser`
	FromChannel *Channel

	// FromAdmin filters messages that are only meant to be said by an admin
	FromAdmin bool

	// FromGroup filters messages that are not from these groups
	FromGroup []slack.Group

	// FromInternalGroup filters out messages not from these groups
	FromInternalGroup []string

	// PrivateOnly filters out public messages.
	PrivateOnly bool

	// PublicOnly filters out private messages.  Mutually exclusive
	// with `PrivateOnly`.
	PublicOnly bool

	// Contains checks whether the `string` is in the message body
	// (after lower-casing both components).
	Contains string

	// ContainsAny checks that any one of the specified strings exist
	// as substrings in the message body.  Mutually exclusive with
	// `Contains`.
	ContainsAny []string

	// Matches checks that the given text matches the given Regexp
	// with a `FindStringSubmatch` call. It will set the `Message.Match`
	// attribute.
	//
	// NOTE: if you spin off a goroutine in the MessageHandlerFunc,
	// make sure to keep a copy of the `Message.Match` object because it
	// will be overwritten by the next Listener the moment your
	// MessageHandlerFunc unblocks.
	Matches *regexp.Regexp

	// ListenForEdits will trigger a message when a user edits a
	// message as well as creates a new one.
	ListenForEdits bool

	// MentionsMe filters out messages that do not mention the Bot's
	// `bot.Config.MentionName`
	MentionsMeOnly bool

	// MatchMyMessages equal to false filters out messages that the bot
	// itself sent.
	MatchMyMessages bool

	// MessageHandlerFunc is a handling function provided by the user, and
	// called when a relevant message comes in.
	MessageHandlerFunc func(*Listener, *Message)

	// EventHandlerFunc is a handling function provided by the user, and
	// called when any event is received. These messages are dispatched
	// to each Listener in turn, after the bot has processed it.
	// If the event is a Message, then the `bawt.Message` will be non-nil.
	//
	// When receiving a `*slack.MessageEvent`, bawt will wrap it in a `*bawt.Message`
	// which embeds the the original event, but adds quite a few functionalities, like
	// reply modes, etc..
	EventHandlerFunc func(*Listener, interface{})

	TimeoutFunc func(*Listener)

	// Bot is a reference to the bot instance.  It will always be populated before being
	// passed to handler functions.
	Bot *Bot
	// contains filtered or unexported fields
}

Listener monitors slack for matching incoming messages and then handles them using a MessageHandlerFunc or EventHandlerFunc

func (*Listener) Close

func (listen *Listener) Close()

Close terminates the Listener management goroutine, and stops any further listening and message handling

func (*Listener) ReplyAck

func (listen *Listener) ReplyAck() *slack.AckMessage

ReplyAck returns the AckMessage received that corresponds to the Reply on which you called Listen()

func (*Listener) ResetDuration

func (listen *Listener) ResetDuration() error

ResetDuration re-initializes the timeout set by `Listener.ListenDuration`, and continues listening for another such duration.

type Logging

type Logging struct {
	Logger *logrus.Logger
	Level  string `json:"level" mapstructure:"level"`
	Type   string `json:"type" mapstructure:"type"`
}

Logging contains the configuration for logrus

type Message

type Message struct {
	*slack.Msg
	SubMessage *slack.Msg

	MentionsMe  bool
	IsEdit      bool
	FromMe      bool
	FromUser    *slack.User
	FromChannel *Channel

	// Match contains the result of
	// Listener.Matches.FindStringSubmatch(msg.Text), when `Matches`
	// is set on the `Listener`.
	Match []string
	// contains filtered or unexported fields
}

Message represents a specific slack message

func (*Message) AddReaction

func (msg *Message) AddReaction(emoticon string) *Message

AddReaction adds a reaction to a message

func (*Message) Contains

func (msg *Message) Contains(s string) bool

Contains searches for a single string in a noncase-sensitive fashion

func (*Message) ContainsAll

func (msg *Message) ContainsAll(strs []string) bool

ContainsAll searches for all strings in a noncase-sensitive fashion

func (*Message) ContainsAny

func (msg *Message) ContainsAny(strs []string) bool

ContainsAny searches for at least one noncase-sensitive matching string

func (*Message) ContainsAnyCased

func (msg *Message) ContainsAnyCased(strs []string) bool

ContainsAnyCased searches for at least one case-sensitive word

func (*Message) HasPrefix

func (msg *Message) HasPrefix(prefix string) bool

HasPrefix returns true if a message starts with a given string

func (*Message) IsPrivate

func (msg *Message) IsPrivate() bool

IsPrivate determines if a message is private or not

func (*Message) ListenReaction

func (msg *Message) ListenReaction(reactListen *ReactionListener)

ListenReaction listens for a reaction on a message

func (*Message) RemoveReaction

func (msg *Message) RemoveReaction(emoticon string) *Message

RemoveReaction removes a reaction from a message

func (*Message) Reply

func (msg *Message) Reply(text string, v ...interface{}) *Reply

Reply sends a message back to the source it came from, without a mention

func (*Message) ReplyMention

func (msg *Message) ReplyMention(text string, v ...interface{}) *Reply

ReplyMention replies with a @mention named prefixed, when replying in public. When replying in private, nothing is added.

func (*Message) ReplyPrivately

func (msg *Message) ReplyPrivately(text string, v ...interface{}) *Reply

ReplyPrivately replies to the user in an IM

func (*Message) ReplyWithFile added in v0.0.4

func (msg *Message) ReplyWithFile(p FileUploadParameters) *ReplyWithFile

ReplyWithFile replies with a snippet or an attached file

func (*Message) String

func (msg *Message) String() string

String returns a message with field:value as a string

type Mood

type Mood int

Mood is an enum

const (
	// Happy indicates a happy bot
	Happy Mood = iota
	// Hyper indicates a hyper bot
	Hyper
)

type Plugin

type Plugin interface{}

Plugin describes the generic bot plugin

func RegisteredPlugins

func RegisteredPlugins() []Plugin

RegisteredPlugins returns the list of registered plugins

type PluginInitializer

type PluginInitializer interface {
	InitPlugin(*Bot)
}

PluginInitializer describes the interface is used to check which plugins can be initialized during plugin initalization initChatPlugins

type ReactionEvent

type ReactionEvent struct {
	// Type can be `ReactionAdded` or `ReactionRemoved`
	Type      reaction
	User      string
	Emoji     string
	Timestamp string
	Item      struct {
		Type        string `json:"type"`
		Channel     string `json:"channel,omitempty"`
		File        string `json:"file,omitempty"`
		FileComment string `json:"file_comment,omitempty"`
		Timestamp   string `json:"ts,omitempty"`
	}

	// Original objects regarding the reaction, when called on a `Reply`.
	OriginalReply      *Reply
	OriginalAckMessage *slack.AckMessage

	// When called on `Message`
	OriginalMessage *Message

	// Listener is a reference to the thing listening for incoming Reactions
	// you can call .Close() on it after a certain amount of time or after
	// the user you were interested in processed its things.
	Listener *ReactionListener
}

ReactionEvent is a reaction event from Slack

func ParseReactionEvent

func ParseReactionEvent(event interface{}) *ReactionEvent

ParseReactionEvent parses and normalizes reaction events to ReactionEvents

type ReactionListener

type ReactionListener struct {
	ListenUntil    time.Time
	ListenDuration time.Duration
	FromUser       *slack.User
	Emoji          string
	Type           reaction

	HandlerFunc func(listen *ReactionListener, event *ReactionEvent)
	TimeoutFunc func(*ReactionListener)
	// contains filtered or unexported fields
}

ReactionListener listens for reactions and changes in reactions

func (*ReactionListener) Close

func (rl *ReactionListener) Close()

Close closes the connection

func (*ReactionListener) ResetDuration

func (rl *ReactionListener) ResetDuration()

ResetDuration resets the duration timer

func (*ReactionListener) ResetNewDuration

func (rl *ReactionListener) ResetNewDuration(d time.Duration)

ResetNewDuration resets the duration timer and creates a new duration

type Reply

type Reply struct {
	*slack.OutgoingMessage
	// contains filtered or unexported fields
}

Reply represents a reply to bawt

func (*Reply) AddReaction

func (r *Reply) AddReaction(emoji string) *Reply

AddReaction adds a reaction to a reply

func (*Reply) DeleteAfter

func (r *Reply) DeleteAfter(duration string) *Reply

DeleteAfter deletes a reply after a certain duration

func (*Reply) Listen

func (r *Reply) Listen(listen *Listener) error

Listen here on Reply is the same as Bot.Listen except that ReplyAck() will be filled with the slack.AckMessage before any event is dispatched to this listener.

func (*Reply) ListenReaction

func (r *Reply) ListenReaction(reactListen *ReactionListener)

ListenReaction listens for reactions

func (*Reply) OnAck

func (r *Reply) OnAck(f func(ack *slack.AckMessage))

OnAck allows you to catch the message_id of the message you replied. Call it immediately after sending a reply to be sure to catch the confirmation message with the message_id.

With the message_id, you can modify your reply, add reactions to it or delete it.

func (*Reply) Updateable

func (r *Reply) Updateable() *UpdateableReply

Updateable returns an instance of UpdateableReply, which has a few methods to update a message after the fact. It is safe to use in different goroutines no matter when.

type ReplyWithFile added in v0.0.4

type ReplyWithFile struct {
	*slack.File
	// contains filtered or unexported fields
}

ReplyWithFile replies to a user with a file

type SlackConfig

type SlackConfig struct {
	Nickname       string
	JoinChannels   []string `json:"join_channels" mapstructure:"join_channels"`
	GeneralChannel string   `json:"general_channel" mapstructure:"general_channel"`
	TeamDomain     string   `json:"team_domain" mapstructure:"team_domain"`
	TeamID         string   `json:"team_id" mapstructure:"team_id"`
	APIToken       string   `json:"api_token" mapstructure:"api_token"`
	WebBaseURL     string   `json:"web_base_url" mapstructure:"web_base_url"`
	DBPath         string   `json:"db_path" mapstructure:"db_path"`
}

SlackConfig holds the configuration to connect with a given slack organization

type UpdateableReply

type UpdateableReply struct {
	// contains filtered or unexported fields
}

UpdateableReply is a Reply that the bot sent, and that it is able to update after the fact.

func (*UpdateableReply) Update

func (u *UpdateableReply) Update(format string, v ...interface{})

Update updates a reply

func (*UpdateableReply) UpdatePrefix

func (u *UpdateableReply) UpdatePrefix(format string, v ...interface{})

UpdatePrefix updates a reply with a prefix

func (*UpdateableReply) UpdateSuffix

func (u *UpdateableReply) UpdateSuffix(format string, v ...interface{})

UpdateSuffix updates a reply with a suffix

type WebPlugin

type WebPlugin interface {
	InitWebPlugin(bot *Bot, private *mux.Router, public *mux.Router)
}

WebPlugin initializes plugins with a `Bot` instance, a `privateRouter` and a `publicRouter`. All URLs handled by the `publicRouter` must start with `/public/`.

type WebServer

type WebServer interface {
	// Used internally by the `bawt` library.
	InitWebServer(*Bot, []string)
	RunServer()

	// Used by an Auth provider.
	SetAuthMiddleware(func(http.Handler) http.Handler)
	SetAuthenticatedUserFunc(func(req *http.Request) (*slack.User, error))

	// Can be called by any plugins.
	PrivateRouter() *mux.Router
	PublicRouter() *mux.Router
	GetSession(*http.Request) *sessions.Session
	AuthenticatedUser(*http.Request) (*slack.User, error)
}

WebServer describes the interface for webserver plugins

type WebServerAuth

type WebServerAuth interface {
	InitWebServerAuth(bot *Bot, webserver WebServer)
}

WebServerAuth returns a middleware warpping the passed on `http.Handler`. Only one auth handler can be added.

Directories

Path Synopsis
Package asana is a plugin for bawt that interacts with Asana
Package asana is a plugin for bawt that interacts with Asana
Package github is a plugin for bawt that interacts with GitHub
Package github is a plugin for bawt that interacts with GitHub
Package healthy is a bawt plugin that evaluates whether URLs return 200's or not
Package healthy is a bawt plugin that evaluates whether URLs return 200's or not
legacy-plugins
Package plotberry is a plugin for bawt that reads Plotly graphs
Package plotberry is a plugin for bawt that reads Plotly graphs
Package recognition is a plugin for bawt that recognizes team members
Package recognition is a plugin for bawt that recognizes team members
Package standup is a plugin for bawt that facilitates standups for teams
Package standup is a plugin for bawt that facilitates standups for teams
Package todo is a plugin for bawt that creates to do lists per channel
Package todo is a plugin for bawt that creates to do lists per channel
pulled from this anonymous playground http://play.golang.org/p/x4CoUsJ5tK
pulled from this anonymous playground http://play.golang.org/p/x4CoUsJ5tK
Package vote is a plugin for bawt that aids in picking a lunch spot
Package vote is a plugin for bawt that aids in picking a lunch spot
Package wicked is a plugin for bawt that facilitates conferences over Slack
Package wicked is a plugin for bawt that facilitates conferences over Slack

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL