Repository: Netflix/hal-9001 Branch: master Commit: 2b3799df56b5 Files: 71 Total size: 467.1 KB Directory structure: gitextract_tlficdtk/ ├── .gitignore ├── LICENSE ├── NOTICE ├── OSSMETADATA ├── README.md ├── brokers/ │ ├── console/ │ │ └── broker.go │ ├── hipchat/ │ │ └── broker.go │ └── slack/ │ └── broker.go ├── example/ │ ├── demos/ │ │ ├── colorparser.go │ │ ├── imgtable.go │ │ └── utf8table.go │ ├── docker-repl/ │ │ └── main.go │ ├── everything/ │ │ └── main.go │ ├── minimal/ │ │ └── main.go │ └── repl/ │ └── main.go ├── hal/ │ ├── asciitable.go │ ├── asciitable_test.go │ ├── broker.go │ ├── cmd.go │ ├── cmd_test.go │ ├── counter.go │ ├── directory.go │ ├── event.go │ ├── event_test.go │ ├── kv.go │ ├── logger.go │ ├── logger_test.go │ ├── periodic.go │ ├── persist_plugins.go │ ├── plugins.go │ ├── prefs.go │ ├── router.go │ ├── secrets.go │ ├── secrets_test.go │ ├── sqldb.go │ ├── text2image.go │ ├── text2image_test.go │ ├── ttlcache.go │ ├── ttlcache_test.go │ ├── utf8table.go │ └── utf8table_test.go └── plugins/ ├── archive/ │ └── plugin.go ├── blabber/ │ └── plugin.go ├── cross_the_streams/ │ └── plugin.go ├── docker/ │ └── plugin.go ├── google_calendar/ │ ├── google.go │ └── plugin.go ├── guys/ │ └── plugin.go ├── inspect/ │ └── plugin.go ├── mark/ │ └── plugin.go ├── pagerduty/ │ ├── helpers.go │ ├── oncall_plugin.go │ ├── page_plugin.go │ ├── pd_events_v1.go │ ├── pd_events_v2.go │ ├── pd_oncall.go │ ├── pd_policy.go │ ├── pd_schedule.go │ ├── pd_service.go │ ├── pd_team.go │ ├── pd_types.go │ ├── pd_user.go │ ├── plugin.go │ └── poller.go ├── pluginmgr/ │ └── plugin.go ├── prefmgr/ │ ├── http.go │ └── plugin.go ├── roster/ │ └── plugin.go ├── seppuku/ │ └── plugin.go ├── spam/ │ └── plugin.go └── uptime/ └── plugin.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a *.so # Folders _obj _test # Architecture specific extensions/prefixes *.[568vq] [568vq].out *.cgo1.go *.cgo2.c _cgo_defun.c _cgo_gotypes.go _cgo_export.* _testmain.go *.exe *.test *.prof .idea *.iml *.swp example/everything/everything example/minimal/minimal example/repl/repl plugins/*/console/console ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: NOTICE ================================================ The font in hal/text2image.go is derived from the IBM VGA 8x16 font from http://int10h.org/oldschool-pc-fonts/ which is under Creative Commons Attribution-ShareAlike 4.0 International. ================================================ FILE: OSSMETADATA ================================================ osslifecycle=active ================================================ FILE: README.md ================================================ # Hal-9001 Hal-9001 is a Go library that offers a number of facilities for creating a bot and its plugins. # Goals * make easy things easy and hard things accessible * 15 minutes from getting started to a working bot * optimize for long-term maintenance # Requirements * Go >= 1.5 It should build with older versions of Go but it has not been tested. # Creating your own bot The easiest place to start is with the examples in the examples directory. Take a look at what's there and copy the main.go of your favorite into a new repo and start editing it to your taste. examples/everything/main.go has the most coverage of Hal's features and has commentary throughout the file that should help you get going. TODO: add more of a tutorial here / on the wiki # Building A few dependencies are required by Hal's core library and plugins. For building the examples/everything demo, you will need the following. Hal core requires at least the mysql driver to build. Everything else is a dependency of a plugin or broker and can be omitted if you don't import those. ``` go get github.com/nlopes/slack go get github.com/mattn/go-xmpp go get github.com/codegangsta/cli go get github.com/go-sql-driver/mysql # optional - currently only used in examples/repl go get gopkg.in/DATA-DOG/go-sqlmock.v1 ``` # Using Hal in chat Most bots built with hal start the pluginmgr plugin first. The pluginmgr allows users to enable and configure plugins from inside the chat system. e.g. ``` !plugin attach uptime !plugin detach uptime !plugin attach uptime --regex ^[[:space:]]*!up !plugin list ``` # Terminology ### Event Hal's events (hal.Evt) are an abstraction of the messages/events that brokers produce/consume. An event has a Body, User, Room, and timestamp. The handle offers some convenience methods for replying to events and other tedious bits around processing them. ### Broker A broker is a 2-way producer/consumer of events. The code that hooks hal up to Slack, Hipchat, and others are brokers. There is a hal.Broker interface that defines the required behavior of brokers. ### Plugin A plugin is a function that processes events with metadata. Plugins do nothing until they are attached to a room in the plugin manager. ### Instance An instance is a plugin that has been attached to a room. ### Room Hal calls all channels/rooms/related concepts rooms. Mostly "room" was picked because calling things channels in Go code gets confusing when you're also using channels extensively. # Authoring Plugins Hal plugins should be in a package. You can have more than one plugin per package. Some ship with Hal, others are in their own repos and can be added with go get/import. Because plugins are not activated automatically and can be bound to channels with separate configs, they have to be registered and then instantiated. ```go package uptime // uptime: the simplest useful plugin possible import ( "time" "github.com/netflix/hal-9001/hal" ) var booted time.Time func init() { booted = time.Now() } // The plugin's Register() should be called from main() in the bot to // make the plugin available for use at runtime. It can be called anything // you like, but most of the plugins call it Register(). // // Plugins are not tied to a specific broker so if it is going to use // the evt.Original field, be careful about double-checking the type // of message or evt.Broker to make sure it's safe to use. func Register() { p := hal.Plugin{ Name: "uptime", Func: uptime, Regex: "^!uptime", } p.Register() } // uptime implements the plugin itself func uptime(evt hal.Evt) { ut := time.Since(booted) evt.Replyf("uptime: %s", ut.String()) } ``` # Rationale Some constructs in Hal are the result of a few decisions that deserve explanation. ## MySQL as the only supported database driver Right now, only mysql-compatible database backends are supported. This is unlikely to change. Coding directly against a specific database allows Hal to use database-specific features and avoid unncessarry abstractions or loss of power required to support other databases. Netflix runs its bot in AWS using Aurora with local testing against MariaDB. ## missing tests & ubiquitous assertions This is not a permanent situation. The API changed a lot as the bot was being built and tests were frequently invalidated. Now that the API is more stable, tests are being added back over time. In order to speed up development and reduce the frequence of error checking in plugin/bot code, many parts of hal simply crash the program when errors occur. This makes assumptions about errors obvious and immediately visible without having to bubble errors up into consumer code at the cost of having to run your hal bot under a supervisor. When reasons are found to convert fatal errors into error returns, code should be refactored to do so. # TODO - [ ] implement sensible REST patterns for HTTP endpoints - [ ] work on the TODOs sprinkled throughout the code - [ ] provide more examples, e.g. slack-only, hipchat-only, console + slack - [ ] logging hooks to redirect logs to a channel - [ ] revive/update the Docker plugin - [ ] update constants to match the Go standards # Future Ideas * [in progress] a Docker plugin that runs code in Docker over stdio * exists, but is not ready to be released yet * integrate sshchat as a broker or an maybe an ssh server for admin stuff * build in a simple arg parser something like evt.Getopts() along the lines of evt.BodyAsArgv() # Community The hangops slack seems like as good a place as any to start out. Bot presence coming soon. https://hangops.slack.com/messages/hal-9001/ # Author Al Tobey # License Apache 2 ================================================ FILE: brokers/console/broker.go ================================================ package console /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bufio" "fmt" "io" "os" "strings" "time" "github.com/chzyer/readline" "github.com/netflix/hal-9001/hal" ) var log hal.Logger type Config struct{} type Broker struct { User string Room string Topic string Stdin chan string Stdout chan string } type SlashReaction string // REPL starts a readline-like bot REPL on the console. // All plugins that are present and registered are automatically enabled. // name should be a non-empty string. It is reported as the room name/id and // will be the string in the REPL prompt, e.g. "foo" -> "foo> ". // If prefix is set, it is prepended to every line so you can do, e.g. // REPL("foo", "!foo") and every line in the REPL will show up in the hal // Evt.Body as "!foo ". To avoid this, set it to empty string. // This will start 2 goroutines. func REPL(name, prefix string) { conf := Config{} broker := conf.NewBroker(name) router := hal.Router() router.AddBroker(broker) go router.Route() // automatically wire up all loaded & registered plugins pr := hal.PluginRegistry() for _, p := range pr.PluginList() { i := p.Instance(broker.Room, broker) i.Register() } lines := make(chan string, 100) quit := make(chan struct{}, 1) // a simple forwarder - lines from the REPL are forwarded to the router // lines from the router are printed to stdout // when the user quits, the goroutine is shut down gracefully go func() { for { select { case <-quit: close(quit) close(lines) return case line := <-broker.Stdout: println(line) case line := <-lines: broker.Stdin <- line } } }() rl, err := readline.New(name + "> ") if err != nil { panic(err) } defer rl.Close() // block forever reading lines from stdin for { line, err := rl.Readline() if err == io.EOF { time.Sleep(time.Millisecond * 100) continue } else if err != nil { panic(err) } // quit or exit immediately exit the REPL trimmed := strings.Trim(line, "\r\n ") if trimmed == "quit" || trimmed == "exit" { break } // no input, user likely hit enter on an empty line if trimmed == "" { continue } // e.g. prefix="!prefs" translates "list" to "!prefs list" // so the plugin system automatically takes care of things // without hacks in the core code if prefix != "" { lines <- prefix + " " + strings.Trim(line, " ") } else { lines <- line } } quit <- struct{}{} } // NewBroker returns a new console.Broker. func (c Config) NewBroker(name string) Broker { user := os.Getenv("USER") if user == "" { user = "testuser" } out := Broker{ User: user, Room: name, Stdin: make(chan string, 1000), Stdout: make(chan string, 1000), } return out } func (cb Broker) Name() string { return cb.Room } func (cb Broker) Send(e hal.Evt) { cb.Stdout <- e.Body } func (cb Broker) SendDM(e hal.Evt) { cb.Stdout <- e.Body } func (cb Broker) Leave(roomId string) error { log.Println("Leave(roomId string) not implemented.") return nil } func (cb Broker) GetTopic(roomId string) (string, error) { return cb.Topic, nil } func (cb Broker) SetTopic(roomId, topic string) error { cb.Topic = topic cb.Stdout <- fmt.Sprintf("topic set to: %q", topic) return nil } func (cb Broker) SendTable(e hal.Evt, hdr []string, rows [][]string) { cb.Stdout <- hal.Utf8Table(hdr, rows) } func (cb Broker) LooksLikeRoomId(room string) bool { return true } func (cb Broker) LooksLikeUserId(user string) bool { return true } // SimpleStdin will loop forever reading stdin and publish each line // as an event in the console broker. // e.g. go cbroker.SimpleStdin() func (cb Broker) SimpleStdin() { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { line := scanner.Text() if err := scanner.Err(); err != nil { log.Fatalf("Failed while reading from stdin: %s\n", err) } // ignore empty lines if len(line) == 0 { continue } cb.Stdin <- line } } // SimpleStdout prints all replies, etc to the broker on os.Stdout. // e.g. go cbroker.SimpleStdout() func (cb Broker) SimpleStdout() { for { select { case txt := <-cb.Stdout: // events from the Reply() method go through a go channel _, err := os.Stdout.WriteString(txt) if err != nil { log.Fatalf("Could not write to stdout: %s\n", err) } } } } func (cb Broker) Stream(out chan *hal.Evt) { for { input := <-cb.Stdin now := time.Now() e := hal.Evt{ ID: fmt.Sprintf("%d.%06d", now.Unix(), now.UnixNano()), User: cb.User, UserId: cb.User, Room: cb.Room, RoomId: cb.Room, Body: input, Time: now, Broker: cb, IsChat: true, Original: &input, } if strings.HasPrefix(e.Body, "/") { args := e.BodyAsArgv() // detect slash commands for creating specialized event types switch args[0] { case "/reaction": if len(args) == 2 { e.Body = args[1] // re-cast the reaction as a type that can be introspected by plugins orig := SlashReaction(args[1]) e.Original = &orig } else { e.IsChat = true e.Reply("/reaction requires exactly one argument!") } } } else { // everything else is just a plain chat event out <- &e } } } // required by interface func (b Broker) RoomIdToName(in string) string { return in } func (b Broker) RoomNameToId(in string) string { return in } func (b Broker) UserIdToName(in string) string { return in } func (b Broker) UserNameToId(in string) string { return in } ================================================ FILE: brokers/hipchat/broker.go ================================================ package hipchat /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "strings" "time" "github.com/mattn/go-xmpp" "github.com/netflix/hal-9001/hal" ) var log hal.Logger // Broker contains the Hipchat API handles required for interacting // with the hipchat service. type Broker struct { Client *xmpp.Client Config Config inst string } type Config struct { Host string Jid string Password string Rooms map[string]string } // HIPCHAT_HOST is the only supported hipchat host. const HIPCHAT_HOST = `chat.hipchat.com:5223` // Hipchat is a singleton that returns an initialized and connected // Broker. It can be called anywhere in the bot at any time. // Host must be "chat.hipchat.com:5223". This requirement can go away // once someone takes the time to integrate and test against an on-prem // Hipchat server. func (c Config) NewBroker(name string) Broker { // TODO: remove this once the TLS/SSL requirements are sorted if c.Host != HIPCHAT_HOST { log.Println("TODO: Only SSL and hosted Hipchat are supported at the moment.") log.Printf("Hipchat host must be %q.", HIPCHAT_HOST) } // for some reason Go's STARTTLS seems to be incompatible with // Hipchat's or maybe Hipchat TLS is broken, so don't bother and use SSL. options := xmpp.Options{ Host: c.Host, User: c.Jid, Debug: false, Password: c.Password, Resource: "bot", Session: true, Status: "Available", StatusMessage: "Hal-9001 online.", } client, err := options.NewClient() if err != nil { log.Fatalf("Could not connect to Hipchat over XMPP: %s\n", err) } for jid, name := range c.Rooms { _, err = client.JoinMUCNoHistory(jid, name) if err != nil { log.Fatalf("Could not join room %q/%q: %s", name, jid, err) } } hb := Broker{ Client: client, Config: c, inst: name, } return hb } func (hb Broker) Name() string { return hb.inst } func (hb Broker) Send(evt hal.Evt) { remote := fmt.Sprintf("%s/%s", evt.RoomId, hb.RoomIdToName(evt.RoomId)) msg := xmpp.Chat{ Text: evt.Body, Stamp: evt.Time, Type: "groupchat", Remote: remote, } _, err := hb.Client.Send(msg) if err != nil { log.Printf("Failed to send message to Hipchat server: %s\n", err) } } // TODO: implement this - if Atlassian ever re-publishes the API docs. func (hb Broker) SendDM(e hal.Evt) { panic("SendDM not implemented in Hipchat yet.") } // TODO: this is untested and may not be entirely correct func (hb Broker) Leave(roomId string) error { for jid, name := range c.Rooms { if roomId == name { _, err := hb.Client.LeaveMUC(jid) return err } } return fmt.Errorf("Unable to determine JID of room %q.", roomId) } // TODO: implement func (hb Broker) GetTopic(roomId string) (string, error) { panic("SetTopic not implemented in Hipchat yet. Pull requests welcome.") } // TODO: implement func (hb Broker) SetTopic(roomId, topic string) error { panic("SetTopic not implemented in Hipchat yet. Pull requests welcome.") } func (hb Broker) SendTable(evt hal.Evt, hdr []string, rows [][]string) { out := evt.Clone() // TODO: verify if this works for bots - works fine in the client // will probably need to post with the API out.Body = fmt.Sprintf("/code %s", hal.Utf8Table(hdr, rows)) hb.Send(out) } func (hb Broker) LooksLikeRoomId(room string) bool { log.Println("brokers/hipchat/LooksLikeRoomId() is a stub that always returns true!") return true } func (hb Broker) LooksLikeUserId(user string) bool { log.Println("brokers/hipchat/LooksLikeUserId() is a stub that always returns true!") return true } // Subscribe joins a room with the given alias. // These names are specific to how Hipchat does things. func (hb *Broker) Subscribe(room, alias string) { // TODO: take a room name and somehow look up the goofy MUC name // e.g. client.JoinMUC("99999_roomName@conf.hipchat.com", "Bot Name") hb.Client.JoinMUCNoHistory(room, alias) hb.Config.Rooms[room] = alias } // Keepalive is a timer loop that can be fired up to periodically // send keepalive messages to the Hipchat server in order to prevent // Hipchat from shutting the connection down due to inactivity. func (hb *Broker) heartbeat(t time.Time) { // this seems to work but returns an error you'll see in the logs msg := xmpp.Chat{ Text: "heartbeat", Stamp: t, } msg.Stamp = t n, err := hb.Client.Send(msg) if err != nil { log.Fatalf("Failed to send keepalive (%d): %s\n", n, err) } } // Stream is an event loop for Hipchat events. func (hb Broker) Stream(out chan *hal.Evt) { client := hb.Client incoming := make(chan *xmpp.Chat) timer := time.Tick(time.Minute * 1) // once a minute // grab chat messages using the blocking Recv() and forward them // on a channel so the select loop can also handle sending heartbeats go func() { for { msg, err := client.Recv() if err != nil { log.Printf("Error receiving from Hipchat: %s\n", err) } switch t := msg.(type) { case xmpp.Chat: m := msg.(xmpp.Chat) incoming <- &m case xmpp.Presence: continue // ignored default: log.Printf("Unhandled message of type '%T': %s ", t, t) } } }() for { select { case t := <-timer: hb.heartbeat(t) case chat := <-incoming: // Remote should look like "99999_roomName@conf.hipchat.com/User Name" parts := strings.SplitN(chat.Remote, "/", 2) now := time.Now() if len(parts) == 2 { // XMPP doesn't have IDs, use time like Slack e := hal.Evt{ ID: fmt.Sprintf("%d.%06d", now.Unix(), now.UnixNano()), Body: chat.Text, Room: hb.RoomIdToName(parts[0]), RoomId: parts[0], User: parts[1], UserId: chat.Remote, Time: now, // m.Stamp seems to be zeroed Broker: hb, IsChat: true, Original: &chat, } out <- &e } else { log.Printf("hipchat broker received an unsupported message: %+v", chat) } } } } // only considers rooms that have been configured in the bot // and does not hit the Hipchat APIs at all // TODO: hit the API and get the room/name lists and cache them func (b Broker) RoomIdToName(in string) string { if name, exists := b.Config.Rooms[in]; exists { return name } return "" } func (b Broker) RoomNameToId(in string) string { for id, name := range b.Config.Rooms { if name == in { return id } } return "" } func (b Broker) UserIdToName(in string) string { return in } func (b Broker) UserNameToId(in string) string { return in } ================================================ FILE: brokers/slack/broker.go ================================================ package slack /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "image" "image/color" "image/draw" "image/png" "io/ioutil" "os" "regexp" "strconv" "strings" "sync" "time" "github.com/netflix/hal-9001/hal" "github.com/nlopes/slack" ) var log hal.Logger // Broker interacts with the slack service. // TODO: add a miss cache to avoid hammering the room/user info apis type Broker struct { Client *slack.Client // slack API object RTM *slack.RTM // slack RTM object UserId string // slack Bot user ID (for preventing loops) inst string // broker instance name i2u map[string]string // id->name cache i2c map[string]string // id->name cache u2i map[string]string // name->id cache c2i map[string]string // name->id cache imcs map[string]string // userId -> channelId im channels lufill time.Time // timestamp of the last user cache fill lrfill time.Time // timestamp of the last room cache fill idRegex *regexp.Regexp // compiled RE to match user/room ids mut sync.Mutex // protect access to the lookup maps } type Config struct { Token string } var LooksLikeIdRE *regexp.Regexp func init() { LooksLikeIdRE = regexp.MustCompile(`^[UCD]\w{8}$`) log.SetPrefix("brokers/slack") } func (c Config) NewBroker(name string) Broker { client := slack.New(c.Token) // TODO: check for failures and log.Fatalf() rtm := client.NewRTM() sb := Broker{ Client: client, RTM: rtm, inst: name, i2u: make(map[string]string), i2c: make(map[string]string), u2i: make(map[string]string), c2i: make(map[string]string), imcs: make(map[string]string), } // fill the caches at startup to cut down on API requests sb.FillUserCache() sb.FillRoomCache() go rtm.ManageConnection() return sb } // Name returns the name of the broker as set in NewBroker. func (sb Broker) Name() string { return sb.inst } func (sb Broker) Send(evt hal.Evt) { // Slack refuses messages over 4000 characters. Most of the time that's // probably data so post it as a file. Using len instead of rune count since // slack is probably looking at bytes. if len(evt.Body) > 3999 { sb.SendAsSnippet(evt) } else { sb.SendAsIs(evt) } } func (sb Broker) SendAsSnippet(evt hal.Evt) { f, err := ioutil.TempFile(os.TempDir(), "hal") if err != nil { evt.Replyf("Could not create tempfile for large text upload: %s", err) return } defer os.Remove(f.Name()) f.WriteString(evt.Body) f.Close() // upload the file params := slack.FileUploadParameters{ File: f.Name(), Filename: "reply.txt", Channels: []string{evt.RoomId}, } _, err = sb.Client.UploadFile(params) if err != nil { evt.Replyf("Could not upload snippet file: %s", err) } } // SendAsIs directly sends a message without considering it for posting as a snippet. func (sb Broker) SendAsIs(evt hal.Evt) { // if evt.Original is a slack.PostMessageParameters, assume that means that there is // a rich message in the body with params that need to be posted to the web API // rather than going through RTM. // See: https://api.slack.com/bot-users switch evt.Original.(type) { case *slack.PostMessageParameters: params := evt.Original.(*slack.PostMessageParameters) params.AsUser = true // if we've gotten here, we always want this sb.Client.PostMessage(evt.RoomId, evt.Body, *params) default: om := sb.RTM.NewOutgoingMessage(evt.Body, evt.RoomId) sb.RTM.SendMessage(om) } } func (sb Broker) SendDM(evt hal.Evt) { evt.Room = "" evt.RoomId = "" if roomId, exists := sb.imcs[evt.UserId]; exists { // cache hit // TODO: verify what happens if the destination user has closed the DM evt.RoomId = roomId } else { // try to open the channel, cache it if it works _, _, roomId, err := sb.RTM.OpenIMChannel(evt.UserId) if err != nil { log.Printf("Error from RTM.OpenIMChannel(%q): %s", evt.UserId, err) } else { sb.imcs[evt.UserId] = roomId sb.i2c[roomId] = evt.UserId // TODO: verify this isn't a stupid idea evt.RoomId = roomId } } if evt.RoomId != "" { sb.Send(evt) } else { log.Printf("SendDM() failed because it couldn't identify a DM RoomID!") log.Printf("Failed message: %q", evt.String()) } } func (sb Broker) Leave(roomId string) error { _, err := sb.Client.LeaveChannel(roomId) return err } func (sb Broker) GetTopic(roomId string) (string, error) { ch, err := sb.Client.GetChannelInfo(roomId) return ch.Topic.Value, err } func (sb Broker) SetTopic(roomId, topic string) error { r, err := sb.Client.SetChannelTopic(roomId, topic) log.Debugf("SetTopic(%q, %q) = %q", roomId, topic, r) return err } func (sb Broker) SendTable(evt hal.Evt, hdr []string, rows [][]string) { out := evt.Clone() out.Body = hal.Utf8Table(hdr, rows) tblFmt := hal.FindPrefs("", "", "", "", "table.format").One() if tblFmt.Value == "image" { sb.SendAsImage(out) } else if tblFmt.Value == "snippet" { sb.SendAsSnippet(out) } else { sb.SendAsIs(out) } } // SendAsImage sends the body of the event as a png file. The png is rendered // using hal's FixedFont facility. // This is useful for making sure pre-formatted text stays legible in // Slack while we wait for them to figure out a way to render things like // tables of data consistently. func (sb Broker) SendAsImage(evt hal.Evt) { fd := hal.FixedFont() // create a tempfile f, err := ioutil.TempFile(os.TempDir(), "hal") if err != nil { evt.Replyf("Could not create tempfile for image upload: %s", err) return } defer os.Remove(f.Name()) // check for a color preference // need to figure out a way to have a helper around this var fg color.Color fg = color.Black // TODO: prefs --set isn't setting the room, etc. remove the filter for now fgprefs := hal.FindPrefs("", "", "", "", "image.fg") ufgprefs := fgprefs.User(evt.UserId) if len(ufgprefs) > 0 { fg = fd.ParseColor(ufgprefs[0].Value, fg) } else if len(fgprefs) > 0 { fg = fd.ParseColor(fgprefs[0].Value, fg) } var bg color.Color bg = color.Transparent // TODO: ditto from ft //bgprefs := hal.FindPrefs("", sb.Name(), evt.RoomId, "", "image.bg") bgprefs := hal.FindPrefs("", "", "", "", "image.bg") ubgprefs := bgprefs.User(evt.UserId) if len(ubgprefs) > 0 { bg = fd.ParseColor(ubgprefs[0].Value, fg) } else if len(bgprefs) > 0 { bg = fd.ParseColor(bgprefs[0].Value, fg) } // generate the image lines := strings.Split(strings.TrimSpace(evt.Body), "\n") textimg := fd.StringsToImage(lines, fg) // img has a background color, copy textimg onto it img := image.NewRGBA(textimg.Bounds()) draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.ZP, draw.Src) draw.Draw(img, img.Bounds(), textimg, image.ZP, draw.Src) // TODO: apply background color // write the png data to the temp file png.Encode(f, img) f.Close() // upload the file params := slack.FileUploadParameters{ File: f.Name(), Filename: "text.png", Channels: []string{evt.RoomId}, } _, err = sb.Client.UploadFile(params) if err != nil { evt.Replyf("Could not upload image: %s", err) } } func (sb Broker) LooksLikeRoomId(room string) bool { sb.mut.Lock() defer sb.mut.Unlock() if _, exists := sb.i2c[room]; exists { return true } return LooksLikeIdRE.MatchString(room) } func (sb Broker) LooksLikeUserId(user string) bool { sb.mut.Lock() defer sb.mut.Unlock() if _, exists := sb.i2u[user]; exists { return true } return LooksLikeIdRE.MatchString(user) } // checks the cache to see if the room is known to this broker func (sb Broker) HasRoom(room string) bool { sb.mut.Lock() defer sb.mut.Unlock() if LooksLikeIdRE.MatchString(room) { _, exists := sb.i2c[room] return exists } else { _, exists := sb.c2i[room] return exists } } // Stream is an event loop for Slack events & messages from the RTM API. // Events are copied to a hal.Evt and forwarded to the exchange where they // can be processed by registered handlers. func (sb Broker) Stream(out chan *hal.Evt) { for { select { case msg := <-sb.RTM.IncomingEvents: switch ev := msg.Data.(type) { case *slack.UserTypingEvent: // frequent and mostly useless in a bot: ignore case *slack.HelloEvent: log.Debugf("HelloEvent") case *slack.ConnectedEvent: info := sb.RTM.GetInfo() sb.UserId = info.User.ID log.Debugf("ConnectedEvent - retreived bot ID %q", sb.UserId) case *slack.MessageEvent: // https://api.slack.com/events/message m := msg.Data.(*slack.MessageEvent) // mark messages generated by the bot user to prevent loops, etc. // but pass them through so stuff like the archive module can get them isBot := m.User == sb.UserId // A few other kinds of events are bundled as messages with a subtype. // Only allow isChat to remain true if it's an actual chat message. isChat := m.SubType == "" // slack channels = hal rooms, see hal-9001/hal/event.go e := hal.Evt{ ID: m.Timestamp, Body: m.Text, Room: sb.RoomIdToName(m.Channel), RoomId: m.Channel, User: sb.UserIdToName(m.User), UserId: m.User, Broker: sb, Time: SlackTime(m.Timestamp), IsChat: isChat, IsBot: isBot, Original: m, } // let everyone know the bot is working if it appears to be a command if !isBot && strings.HasPrefix(strings.TrimSpace(m.Text), "!") { tm := sb.RTM.NewTypingMessage(m.Channel) sb.RTM.SendMessage(tm) } out <- &e case *slack.StarAddedEvent: sae := msg.Data.(*slack.StarAddedEvent) if sae.User == sb.UserId { log.Debugf("ignoring event from bot with id %s", sb.UserId) continue // ignore bot-created events } user := sb.UserIdToName(sae.User) e := hal.Evt{ ID: sae.EventTimestamp, Body: fmt.Sprintf("%q added a star", user), Room: sb.RoomIdToName(sae.Item.Channel), RoomId: sae.Item.Channel, User: user, UserId: sae.User, Broker: sb, Time: SlackTime(sae.EventTimestamp), Original: sae, } out <- &e case *slack.StarRemovedEvent: sre := msg.Data.(*slack.StarRemovedEvent) if sre.User == sb.UserId { log.Debugf("ignoring event from bot with id %s", sb.UserId) continue // ignore bot-created events } user := sb.UserIdToName(sre.User) e := hal.Evt{ ID: sre.EventTimestamp, Body: fmt.Sprintf("%q removed a star", user), Room: sb.RoomIdToName(sre.Item.Channel), RoomId: sre.Item.Channel, User: user, UserId: sre.User, Broker: sb, Time: SlackTime(sre.EventTimestamp), Original: sre, } out <- &e case *slack.ReactionAddedEvent: rae := msg.Data.(*slack.ReactionAddedEvent) if rae.User == sb.UserId { log.Debugf("ignoring event from bot with id %s", sb.UserId) continue // ignore bot-created events } user := sb.UserIdToName(rae.User) e := hal.Evt{ ID: rae.EventTimestamp, Body: fmt.Sprintf("%q added reaction %q", user, rae.Reaction), Room: sb.RoomIdToName(rae.Item.Channel), RoomId: rae.Item.Channel, User: user, UserId: rae.User, Broker: sb, Time: SlackTime(rae.EventTimestamp), Original: rae, } out <- &e case *slack.ReactionRemovedEvent: rre := msg.Data.(*slack.ReactionRemovedEvent) if rre.User == sb.UserId { log.Debugf("ignoring event from bot with id %s", sb.UserId) continue // ignore bot-created events } user := sb.UserIdToName(rre.User) e := hal.Evt{ ID: rre.EventTimestamp, Body: fmt.Sprintf("%q removed reaction %q", user, rre.Reaction), Room: sb.RoomIdToName(rre.Item.Channel), RoomId: rre.Item.Channel, User: user, UserId: rre.User, Broker: sb, Time: SlackTime(rre.EventTimestamp), Original: rre, } out <- &e case *slack.ChannelJoinedEvent: je := msg.Data.(*slack.ChannelJoinedEvent) now := time.Now() sb.injectRoomId(je.Channel.ID, je.Channel.Name) // cache the id:name e := hal.Evt{ ID: now.String(), // fake an id Body: je.Channel.Name, Room: je.Channel.Name, RoomId: je.Channel.ID, User: sb.UserId, UserId: sb.UserId, Broker: sb, Time: now, Original: je, } out <- &e case *slack.GroupJoinedEvent: // exactly the same as ChannelJoinedEvent ^^ in a separate type je := msg.Data.(*slack.GroupJoinedEvent) now := time.Now() sb.injectRoomId(je.Channel.ID, je.Channel.Name) // cache the id:name e := hal.Evt{ ID: now.String(), // fake an id Body: je.Channel.Name, Room: je.Channel.Name, RoomId: je.Channel.ID, User: sb.UserId, UserId: sb.UserId, Broker: sb, Time: now, Original: je, } out <- &e case *slack.PresenceChangeEvent: // ignored case *slack.LatencyReport: // ignored case *slack.FileCreatedEvent, *slack.FilePublicEvent, *slack.FileSharedEvent: // ignored case *slack.PrefChangeEvent: // ignored case *slack.RTMError: log.Printf("ignoring RTMError: %s\n", ev.Error()) case *slack.InvalidAuthEvent: log.Debugf("InvalidAuthEvent") break default: log.Debugf("unexpected message: %+v\n", msg) } } } } // SlackTime converts the timestamp string to time.Time func SlackTime(t string) time.Time { if t == "" { return time.Now() } // Slack advises not to parse the timestamp as a float. // I tried it. Turns out that string mangling is more accurate than // float conversions. parts := strings.SplitN(t, ".", 2) s, _ := strconv.ParseInt(parts[0], 10, 64) ns, _ := strconv.ParseInt(parts[1], 10, 64) return time.Unix(s, ns) } func (sb *Broker) FillUserCache() { // don't let this fire more than once every half hour now := time.Now() if now.Sub(sb.lufill) < time.Minute*30 { log.Debugf("refusing to fill cache because it has been less than 30 minutes since the last fill @ %s", sb.lufill.String()) return } sb.lufill = now users, err := sb.Client.GetUsers() if err != nil { log.Printf("failed to fetch user list: %s", err) return } // push the users into the directory async so it doesn't hold up bot // startup (FillUserCache is called preemptively at startup) go func() { for _, user := range users { attrs := map[string]string{ "username": user.Name, "name": user.RealName, "email": user.Profile.Email, } hal.Directory().Put(user.ID, "slack-user", attrs, []string{"email"}) } }() sb.mut.Lock() defer sb.mut.Unlock() for _, user := range users { sb.u2i[user.Name] = user.ID sb.i2u[user.ID] = user.Name } } func (sb *Broker) FillRoomCache() { // don't let this fire more than once every half hour now := time.Now() if now.Sub(sb.lrfill) < time.Minute*30 { log.Printf("refusing to fill cache because it has been less than 30 minutes since the last fill @ %s", sb.lrfill.String()) return } sb.lrfill = now rooms, err := sb.Client.GetChannels(true) if err != nil { log.Printf("failed to fetch room list: %s", err) return } // now get private channels a.k.a. groups groups, err := sb.Client.GetGroups(true) if err != nil { log.Printf("failed to fetch private channel list: %s", err) return } sb.mut.Lock() defer sb.mut.Unlock() for _, room := range rooms { sb.c2i[room.Name] = room.ID sb.i2c[room.ID] = room.Name } for _, group := range groups { sb.c2i[group.Name] = group.ID sb.i2c[group.ID] = group.Name } } // UserIdToName gets the human-readable username for a user ID using an // in-memory cache that falls through to the Slack API func (sb Broker) UserIdToName(id string) string { if id == "" { log.Debugf("UserIdToName(): Cannot look up empty string!") return "" } sb.mut.Lock() name, exists := sb.i2u[id] sb.mut.Unlock() if exists { return name } else { user, err := sb.Client.GetUserInfo(id) if err != nil { log.Printf("could not retrieve user info for '%s' via API: %s\n", id, err) return "" } // don't wait around for this - it can block go func() { attrs := map[string]string{ "username": user.Name, "name": user.RealName, "email": user.Profile.Email, } hal.Directory().Put(user.ID, "slack-user", attrs, []string{"email"}) }() sb.mut.Lock() defer sb.mut.Unlock() sb.i2u[user.ID] = user.Name sb.i2u[user.Name] = user.ID return user.Name } } // RoomIdToName gets the human-readable room name for a user ID using an // in-memory cache that falls through to the Slack API func (sb Broker) RoomIdToName(id string) string { sb.mut.Lock() defer sb.mut.Unlock() if id == "" { log.Debugf("RoomIdToName(): Cannot look up empty string!") return "" } if name, exists := sb.i2c[id]; exists { return name } else { var name string // private channels are on a different endpoint if strings.HasPrefix(id, "G") { grp, err := sb.Client.GetGroupInfo(id) if err != nil { log.Printf("could not retrieve room info for '%s' via API: %s\n", id, err) return "" } name = grp.Name } else if strings.HasPrefix(id, "D") { log.Println("DM CHANNELS ARE A WORK IN PROGRESS") //log.Printf("could not retrieve room info for '%s' via API: %s\n", id, err) } else { room, err := sb.Client.GetChannelInfo(id) if err != nil { log.Printf("could not retrieve room info for '%s' via API: %s\n", id, err) return "" } name = room.Name } sb.i2c[id] = name sb.c2i[name] = id return name } } // UserNameToId gets the human-readable username for a user ID using an // in-memory cache that falls through to the Slack API func (sb Broker) UserNameToId(name string) string { if name == "" { log.Debugf("UserNameToId(): Cannot look up empty string!") return "" } sb.mut.Lock() id, exists := sb.u2i[name] sb.mut.Unlock() if exists { return id } else { // there doesn't seem to be a name->id lookup so refresh the cache // and try again if we get here sb.FillUserCache() sb.mut.Lock() defer sb.mut.Unlock() if id, exists := sb.u2i[name]; exists { return id } log.Printf("service does not seem to have knowledge of username %q", name) return "" } } // RoomNameToId gets the id for a room name using an // in-memory cache that falls through to the Slack API func (sb Broker) RoomNameToId(name string) string { if name == "" { log.Println("RoomNameToId(): Cannot look up empty string!") return "" } sb.mut.Lock() id, exists := sb.c2i[name] sb.mut.Unlock() if exists { return id } else { sb.FillRoomCache() sb.mut.Lock() defer sb.mut.Unlock() if id, exists = sb.c2i[name]; exists { return id } log.Printf("service does not seem to have knowledge of room name %q", name) return "" } } // injectRoomId adds an id:name mapping to the forward and reverse lookup maps // for internal use only, used to inject groups (private channels) on join func (sb Broker) injectRoomId(id, name string) { sb.mut.Lock() defer sb.mut.Unlock() sb.c2i[name] = id sb.i2c[id] = name } ================================================ FILE: example/demos/colorparser.go ================================================ package main // go run utf8table.go /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "github.com/netflix/hal-9001/hal" "image/color" ) func main() { samples := []string{ "ffffff", "ffffffff", "000000ff", "000000aa", "888888ff", "888888", "f79e10", // amber "f79e10ff", // amber with alpha } fd := hal.FixedFont() for _, sample := range samples { result := fd.ParseColor(sample, color.Black) fmt.Printf("%q => %q\n", sample, result) } } ================================================ FILE: example/demos/imgtable.go ================================================ package main // go run utf8table.go /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "github.com/netflix/hal-9001/hal" "image/color" "image/png" "os" "strings" ) func main() { samples := [][][]string{ { {"hdr"}, {"one"}, }, { {"hdr"}, {"one"}, {"two"}, }, { {"left", "right"}, {"one", "three"}, {"two"}, }, { {"HEADER 1", "HDR 2", "LOL WUT"}, {"one", "two", "three"}, {"four", "five", "six"}, }, { {"Col 1", "Col 2", "3rd Column", "4th", "FIFTH"}, {"one", "two", "three"}, {"four", "five", "six"}, {"hi"}, {"", "", "", "-", "+"}, }, } fd := hal.FixedFont() for i, sample := range samples { out := hal.Utf8Table(sample[0], sample[1:]) lines := strings.Split(strings.TrimSpace(out), "\n") img := fd.StringsToImage(lines, color.Black) filename := fmt.Sprintf("%d.png", i) f, err := os.Create(filename) if err != nil { panic(err) } defer f.Close() png.Encode(f, img) fmt.Printf("Created file: %q\n", filename) } } ================================================ FILE: example/demos/utf8table.go ================================================ package main // go run utf8table.go /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "github.com/netflix/hal-9001/hal" ) func main() { samples := [][][]string{ { {"hdr"}, {"one"}, }, { {"hdr"}, {"one"}, {"two"}, }, { {"left", "right"}, {"one", "three"}, {"two"}, }, { {"HEADER 1", "HDR 2", "LOL WUT"}, {"one", "two", "three"}, {"four", "five", "six"}, }, { {"Col 1", "Col 2", "3rd Column", "4th", "FIFTH"}, {"one", "two", "three"}, {"four", "five", "six"}, {"hi"}, {"", "", "", "-", "+"}, }, } for _, sample := range samples { // first row is the header, the rest is data rows out := hal.Utf8Table(sample[0], sample[1:]) fmt.Println(out) } } ================================================ FILE: example/docker-repl/main.go ================================================ package main /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "github.com/chzyer/readline" "github.com/netflix/hal-9001/brokers/console" "github.com/netflix/hal-9001/hal" "github.com/netflix/hal-9001/plugins/docker" "github.com/netflix/hal-9001/plugins/pluginmgr" "github.com/netflix/hal-9001/plugins/prefmgr" ) // a simple bot that only implements generic plugins on a repl // possibly a basis for a command-line client for Slack, etc.... func main() { rl, err := readline.New("hal> ") if err != nil { panic(err) } defer rl.Close() bconf := console.Config{} broker := bconf.NewBroker("cli") docker.Register() pluginmgr.Register() prefmgr.Register() pr := hal.PluginRegistry() pmp, _ := pr.GetPlugin("pluginmgr") pmp.Instance(broker.Room, broker).Register() prmp, _ := pr.GetPlugin("prefmgr") prmp.Instance(broker.Room, broker).Register() dp, _ := pr.GetPlugin("docker") dp.Instance(broker.Room, broker).Register() router := hal.Router() router.AddBroker(broker) go router.Route() for { line, err := rl.Readline() if err != nil { break } broker.Line(line) } } ================================================ FILE: example/everything/main.go ================================================ package main /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "io/ioutil" "log" "net/http" "os" "github.com/netflix/hal-9001/hal" "github.com/netflix/hal-9001/brokers/hipchat" "github.com/netflix/hal-9001/brokers/slack" "github.com/netflix/hal-9001/plugins/archive" "github.com/netflix/hal-9001/plugins/google_calendar" "github.com/netflix/hal-9001/plugins/mark" "github.com/netflix/hal-9001/plugins/pagerduty" "github.com/netflix/hal-9001/plugins/pluginmgr" "github.com/netflix/hal-9001/plugins/prefmgr" "github.com/netflix/hal-9001/plugins/roster" "github.com/netflix/hal-9001/plugins/seppuku" "github.com/netflix/hal-9001/plugins/uptime" ) func main() { // configuration is in environment variables // if you prefer configuration files or flags, that's cool, just replace // this part with your thing dsn := requireEnv("HAL_DSN") keyfile := requireEnv("HAL_SECRETS_KEY_FILE") controlRoom := requireEnv("HAL_CONTROL_ROOM") hipchatRoomJid := requireEnv("HAL_HIPCHAT_ROOM_JID") hipchatRoomName := requireEnv("HAL_HIPCHAT_ROOM_NAME") webAddr := defaultEnv("HAL_HTTP_LISTEN_ADDR", ":9001") // hal provides a k/v API for managing secrets that the DB code uses to get // its DSN (which contains a password). Put the DSN there so the DB can find // it. secrets := hal.Secrets() secrets.Set(hal.SECRETS_KEY_DSN, dsn) // parts of hal rely on the database (prefs, secrets, etc.) // so make sure the DSN is valid and hal can connect before // doing anything else // hal can't do much without the database, so you probably want this db := hal.SqlDB() if err := db.Ping(); err != nil { log.Fatalf("Could not ping the database: %s", err) } // get the secrets encryption key from the file specified // this should be protected like any other private key // if you don't use the secrets persistence, this can be removed/ignored skey, err := ioutil.ReadFile(keyfile) if err != nil { log.Fatalf("Could not read key file '%s': %s", keyfile, err) } // Set the encryption key for persisted secrets. // Secrets can persist to the database, encrypting the key and value // with AES-GCM before writing so that database backups, etc only contain // ciphertext and no cleartext secrets. secrets.SetEncryptionKey(skey) // load secrets from the database secrets.LoadFromDB() // update the DSN again since the database might have a stale copy secrets.Set(hal.SECRETS_KEY_DSN, dsn) // configure the Hipchat broker hconf := hipchat.Config{ Host: hipchat.HIPCHAT_HOST, // TODO: not really configurable yet Jid: secrets.Get("hipchat.jid"), Password: secrets.Get("hipchat.password"), // TODO: make this configurable via prefs (or maybe secrets?) Rooms: map[string]string{ hipchatRoomJid: hipchatRoomName, }, } hc := hconf.NewBroker("hipchat") // configure the Slack broker sconf := slack.Config{ Token: secrets.Get("slack.token"), } slk := sconf.NewBroker("slack") // bind the slack and hipchat plugins to the router router := hal.Router() router.AddBroker(hc) router.AddBroker(slk) // Plugin registration makes them available to the bot but does not // activate them. That happens at runtime using e.g. pluginmgr or // the plugin registry's LoadInstances() (used below) archive.Register() google_calendar.Register() mark.Register() pagerduty.Register() pluginmgr.Register() prefmgr.Register() roster.Register() seppuku.Register() uptime.Register() // start up the router goroutine go router.Route() // load any previously configured plugin instances from the database pr := hal.PluginRegistry() pr.LoadInstances() // pluginmgr is needed to set up all the other plugins // so if it's not present, initialize it manually just this once // alternatively, you could poke config straight into the DB // TODO: remove the hard-coded room name or make it configurable for _, broker := range router.Brokers() { if len(pr.FindInstances(controlRoom, broker.Name(), "pluginmgr")) == 0 { mgr, _ := pr.GetPlugin("pluginmgr") mgrInst := mgr.Instance(controlRoom, broker) mgrInst.Register() } } // temporary ... (2016-03-02) // TODO: remove this or make it permanent by using the same method as // the pluginmgr bootstrap above to set the room name, etc. for _, broker := range router.Brokers() { broker.Send(hal.Evt{ Body: "Ohai! HAL-9001 up and running.", Room: controlRoom, User: "HAL-9001", }) } // start the webserver - some plugins register handlers to the default // net/http router. This makes them available. Remove this if you don't // want the webserver and the handlers will be silently ignored. go func() { err := http.ListenAndServe(webAddr, nil) if err != nil { log.Fatalf("Could not listen on '%s': %s\n", webAddr, err) } }() // block forever select {} } func requireEnv(key string) string { val := os.Getenv(key) if val == "" { log.Fatalf("The %q environment variable is required!", key) } return val } func defaultEnv(key, def string) string { val := os.Getenv(key) if val == "" { return def } return val } ================================================ FILE: example/minimal/main.go ================================================ package main /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import "github.com/netflix/hal-9001/hal" // This bot doesn't do anything except start the router and wait forever // for messages that will never come. // // Most of hal's functionality is optional. It's still built along with the // rest of hal but is not active unless it's used in main or a plugin. func main() { router := hal.Router() router.Route() } ================================================ FILE: example/repl/main.go ================================================ package main /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "gopkg.in/DATA-DOG/go-sqlmock.v1" "github.com/netflix/hal-9001/brokers/console" "github.com/netflix/hal-9001/hal" "github.com/netflix/hal-9001/plugins/pluginmgr" "github.com/netflix/hal-9001/plugins/prefmgr" "github.com/netflix/hal-9001/plugins/uptime" ) // a simple bot that only implements generic plugins on a repl func main() { // SqlInit calls will still throw errors at startup but // it seems the program will continue so this will do for now db, _, err := sqlmock.New() if err != nil { panic(err) } hal.ForceSqlDBHandle(db) defer db.Close() pluginmgr.Register() prefmgr.Register() uptime.Register() console.REPL("repl", "") } ================================================ FILE: hal/asciitable.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bytes" "fmt" "strings" ) // Table takes a 2-dimensional array of strings and returns a single string // formatted in a table appropriate for rendering in a fixed-width font. // Should be suitable for Markdown table rendering. // cheesy 2-pass technique, assuming straight fixed-width ASCII for now func AsciiTable(hdr []string, rows [][]string) string { if len(rows) == 0 { return "NO DATA TO DISPLAY" } else if len(rows[0]) == 0 { panic("BUG: the first row seems to be empty!") } // find the needed width of each column colwidths := make([]int, len(hdr)) // start with the headers' widths for j, col := range hdr { colwidths[j] = len(col) } // bump to the size of any larger cells for i, row := range rows { // handle empty/short rows gracefully by reallocating which // results in a default value of "" if len(row) < len(hdr) { newrow := make([]string, len(hdr)) copy(newrow[0:len(row)], row) rows[i] = newrow row = newrow } for j, col := range row { if colwidths[j] < len(col) { colwidths[j] = len(col) } } } // generate format strings for the columns fmts := make([]string, len(colwidths)) hrcs := make([]string, len(colwidths)) if len(colwidths) > 1 { for i, width := range colwidths { if i == 0 { fmts[i] = fmt.Sprintf("| %%%ds |", width) hrcs[i] = fmt.Sprintf("|%s|", strings.Repeat("-", width+2)) } else if i == len(colwidths)-1 { fmts[i] = fmt.Sprintf(" %%%ds |\n", width) hrcs[i] = fmt.Sprintf("%s|\n", strings.Repeat("-", width+2)) } else { fmts[i] = fmt.Sprintf(" %%%ds |", width) hrcs[i] = fmt.Sprintf("%s|", strings.Repeat("-", width+2)) } } } else { // single-column tables fmts[0] = fmt.Sprintf("| %%%ds |\n", colwidths[0]) hrcs[0] = fmt.Sprintf("|%s|\n", strings.Repeat("-", colwidths[0]+2)) } // horizontal rule hr := strings.Join(hrcs, "") buf := bytes.NewBuffer([]byte{}) fmt.Fprint(buf, hr) for j, col := range hdr { fmt.Fprintf(buf, fmts[j], col) } fmt.Fprintf(buf, hr) for _, row := range rows { for j, col := range row { fmt.Fprintf(buf, fmts[j], col) } } fmt.Fprintf(buf, hr) return buf.String() } ================================================ FILE: hal/asciitable_test.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "testing" ) func TestAsciiTable(t *testing.T) { samples := [][][]string{ { {"hdr"}, {"one"}, }, { {"hdr"}, {"one"}, {"two"}, }, { {"left", "right"}, {"one", "three"}, {"two"}, }, { {"HEADER 1", "HDR 2", "LOL WUT"}, {"one", "two", "three"}, {"four", "five", "six"}, }, { {"Col 1", "Col 2", "3rd Column", "4th", "FIFTH"}, {"one", "two", "three"}, {"four", "five", "six"}, {"hi"}, {"", "", "", "-", "+"}, }, } for _, sample := range samples { // first row is the header, the rest is data rows out := AsciiTable(sample[0], sample[1:]) // not a very useful test ... yet if len(out) == 0 { t.Fail() } fmt.Println(out) } } ================================================ FILE: hal/broker.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Broker is an instance of a broker that can send/receive events. type Broker interface { // the text name of the broker, arbitrary, but usually "slack" or "cli" Name() string Send(evt Evt) SendTable(evt Evt, header []string, rows [][]string) SendDM(evt Evt) SetTopic(roomId, topic string) error GetTopic(roomId string) (topic string, err error) Leave(roomId string) (err error) LooksLikeRoomId(room string) bool LooksLikeUserId(user string) bool RoomIdToName(id string) (name string) RoomNameToId(name string) (id string) UserIdToName(id string) (name string) UserNameToId(name string) (id string) Stream(out chan *Evt) } ================================================ FILE: hal/cmd.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "strconv" "strings" "time" ) /* While it's possible to use the standard library flags or an off-the-github * command-line parser, they have proven to be clunky and often hacky to use. * This API is purpose-built for building bot plugins, focusing on doing the * tedious parts of parsing commands without getting in the way. * Rules: * 1. "*" as user input means "whatever, from the current context" e.g. --room * * 2. "*" as a Cmd.Token means "anything and everything remaining in argv" */ // supported time formats for ParamInst.Time() var TimeFormats = [...]string{ "2006-01-02", "2006-01-02-07:00", "2006-01-02T15:04", "2006-01-02T15:04-07:00", "2006-01-02T15:04:05", "2006-01-02T15:04:05-07:00", } // Cmd models a tree of commands and subcommands along with their parameters. // The tree will almost always be 1 or 2 levels deep. Deeper is possible but // unlikely to be much higher, KISS. // TODO: switc to maps for (kv|bool|idx)params and maybe subCmds type Cmd struct { token string // * => slurp everything remaining usage string subCmds []*SubCmd kvparams []*KVParam boolparams []*BoolParam idxparams map[int]*IdxParam aliases []string prev *Cmd // parent command, nil for root mustSubCmd bool // a subcommand is always required } type SubCmd struct { cmd *Cmd Cmd } type CmdInst struct { cmd *Cmd subCmdInst *SubCmdInst kvparaminsts []*KVParamInst boolparaminsts []*BoolParamInst idxparaminsts map[int]*IdxParamInst remainder []string // args left over after parsing, usually empty } type SubCmdInst struct { subCmd *SubCmd CmdInst } // key/value parameters, e.g. "--foo=bar", "foo=bar", "-f bar", "--foo bar" type KVParam struct { key string // the "foo" in --foo, -f, foo=bar aliases []string // parameter aliases, e.g. foo => f usage string // usage string for generating help required bool // whether or not this parameter is required def string // default value when omitted hasdef bool // whether or not a default has been provided cmd *Cmd // the (top-level) command the param is attached to subcmd *SubCmd // the subcommand the param is attached to } // keyed parameters that are boolean (flags), e.g. "--foo", "-f", "foo=true" type BoolParam struct { key string aliases []string usage string required bool def bool hasdef bool cmd *Cmd subcmd *SubCmd } // positional parameters (0 indexed) type IdxParam struct { idx int // positional arg index name string usage string required bool def string hasdef bool cmd *Cmd subcmd *SubCmd } // KVParamInst represents a key/value parameter found in the command type KVParamInst struct { cmdinst *CmdInst // the top-level command subcmdinst *SubCmdInst // the subcommand the param belongs to, nil for top-level param *KVParam found bool // was the parameter set in the command? isdef bool // was the parameter set using a default? arg string // the original/unmodified argument (e.g. --foo, -f) key string // the key, e.g. "foo" value string } // BoolParamInst represents a flag/boolean parameter found in the command type BoolParamInst struct { cmdinst *CmdInst subcmdinst *SubCmdInst param *BoolParam found bool isdef bool arg string key string value bool } // IdxParamInst represents a positional parameter found in the command type IdxParamInst struct { cmdinst *CmdInst subcmdinst *SubCmdInst param *IdxParam found bool isdef bool idx int name string value string } // tmpParamInst used by the parser to hold keyed parameters before attaching to commands/subcommands. type tmpParamInst struct { cmd *Cmd cmdinst *CmdInst subcmd *SubCmd subcmdinst *SubCmdInst found bool arg string key string value string } type stringValuedParamInst interface { Found() bool Required() bool Value() string String() (string, error) Int() (int, error) Float() (float64, error) Bool() (bool, error) errParam() NamedParam } // cmdorsubcmd is used internally to pass either a Cmd or SubCmd // to a helper function so I don't have to copy/paste the code type cmdorsubcmd interface { HasKVParam(string) bool HasBoolParam(string) bool HasIdxParam(int) bool GetKVParam(string) *KVParam GetBoolParam(string) *BoolParam GetIdxParam(int) *IdxParam appendKVParamInst(*KVParamInst) appendBoolParamInst(*BoolParamInst) appendIdxParamInst(*IdxParamInst) } type NamedParam interface { Name() string Usage() string IsRequired() bool } type SubCmdNotFound struct { argv []string } func (e SubCmdNotFound) Error() string { return fmt.Sprintf("A subcommand is required but %q was provided.", strings.Join(e.argv, " ")) } // RequiredParamNotFound is returned when a parameter has Required=true // and a method was used to access the value but no value was set in the // command. type RequiredParamNotFound struct { Param NamedParam } // Error fulfills the Error interface. func (e RequiredParamNotFound) Error() string { return fmt.Sprintf("Parameter %q is required but not set.", e.Param.Name()) } // UnsupportedTimeFormatError is returned when a provided time string cannot // be parsed with one of the pre-defined time formats. type UnsupportedTimeFormatError struct { value string } // Error fulfills the Error interface for UnsupportedTimeFormatError. func (e UnsupportedTimeFormatError) Error() string { return fmt.Sprintf("Time string %q does not appear to be in a supported format.", e.value) } // NewCmd returns an initialized Cmd. func NewCmd(token string, mustsubcmd bool) *Cmd { cmd := Cmd{token: token, mustSubCmd: mustsubcmd} return &cmd } // ListSubCmds makes sure the SubCmds list is initialized and returns the list. func (c *Cmd) ListSubCmds() []*SubCmd { if c.subCmds == nil { c.subCmds = make([]*SubCmd, 0) } return c.subCmds } // _kvparams makes sure the _kvparams list is initialized and returns the list. func (c *Cmd) _kvparams() []*KVParam { if c.kvparams == nil { c.kvparams = make([]*KVParam, 0) } return c.kvparams } // _boolparams makes sure the _boolparams list is initialized and returns the list. func (c *Cmd) _boolparams() []*BoolParam { if c.boolparams == nil { c.boolparams = make([]*BoolParam, 0) } return c.boolparams } // _idxparams makes sure the _idxparams map is initialized and returns the map. func (c *Cmd) _idxparams() map[int]*IdxParam { if c.idxparams == nil { c.idxparams = make(map[int]*IdxParam) } return c.idxparams } // Aliases makes sure the Aliases list is initialized and returns the list. func (c *Cmd) Aliases() []string { if c.aliases == nil { c.aliases = make([]string, 0) } return c.aliases } // assertZeroIdxParams panics if there are any IdxParam defined. func (c *Cmd) assertZeroIdxParams() { pps := c._idxparams() if len(pps) > 0 { log.Panic("Illegal mixing of positional and key/value parameters.") } } // assertZeroKeyParams panics if there are any BoolParam or KVParam defined. func (c *Cmd) assertZeroKeyParams() { kps := c._kvparams() bps := c._boolparams() if len(kps) > 0 || len(bps) > 0 { log.Panic("Illegal mixing of positional and key/value parameters.") } } // AddKVParam creates and adds a key/value parameter to the command handle // and returns the new parameter. func (c *Cmd) AddKVParam(key string, required bool) *KVParam { c.assertZeroIdxParams() p := KVParam{key: key} p.required = required p.cmd = c.Cmd() c.kvparams = append(c._kvparams(), &p) return &p } // AddBoolParam adds a boolean/flag parameter to the command and returns the // new parameter. func (c *Cmd) AddBoolParam(key string, required bool) *BoolParam { c.assertZeroIdxParams() p := BoolParam{} p.key = key p.required = required p.cmd = c.Cmd() c.boolparams = append(c._boolparams(), &p) return &p } // AddIdxParam adds a positional parameter to the command and returns the // new parameter. func (c *Cmd) AddIdxParam(position int, name string, required bool) *IdxParam { c.assertZeroKeyParams() ips := c._idxparams() if _, exists := ips[position]; exists { log.Panicf("position %d already has an IdxParam defined on this command", position) } ips[position] = &IdxParam{ idx: position, name: name, usage: name, // what you want most of the time required: required, cmd: c.Cmd(), } return ips[position] } // AddKVParam creates and adds a key/value parameter to the subcommand // and returns the new parameter. func (c *SubCmd) AddKVParam(key string, required bool) *KVParam { c.assertZeroIdxParams() p := KVParam{key: key} p.required = required p.cmd = c.cmd p.subcmd = c c.kvparams = append(c._kvparams(), &p) return &p } // AddBoolParam adds a boolean/flag parameter to the subcommand and returns the // new parameter. func (c *SubCmd) AddBoolParam(key string, required bool) *BoolParam { c.assertZeroIdxParams() p := BoolParam{} p.key = key p.required = required p.cmd = c.cmd p.subcmd = c c.boolparams = append(c._boolparams(), &p) return &p } // AddIdxParam adds a positional parameter to the subcommand and returns the // new parameter. func (c *SubCmd) AddIdxParam(position int, name string, required bool) *IdxParam { c.assertZeroKeyParams() ips := c._idxparams() if _, exists := ips[position]; exists { log.Panicf("position %d already has an IdxParam defined on this subcommand", position) } ips[position] = &IdxParam{ idx: position, name: name, usage: name, // what you want most of the time required: required, cmd: c.cmd, subcmd: c, } return ips[position] } // AddAlias adds an alias to the command and returns the paramter. func (c *Cmd) AddAlias(alias string) *Cmd { c.aliases = append(c.Aliases(), alias) return c } func (s *SubCmd) AddAlias(alias string) *SubCmd { s.aliases = append(s.Aliases(), alias) return s } // AddAlias adds an alias to the parameter and returns the paramter. func (p *KVParam) AddAlias(alias string) *KVParam { p.aliases = append(p.Aliases(), alias) return p } func (c *Cmd) Parent() *Cmd { return c.prev } // MustSubCmd returns bool indicating if a subcommand is required. func (c *Cmd) MustSubCmd() bool { return c.mustSubCmd } // Usage returns the auto-generated usage string. func (c *Cmd) Usage() string { out := make([]string, 1) out[0] = c.token + " - " + c.usage for _, scmd := range c.ListSubCmds() { params := scmd.ListNamedParams() opttxt := make([]string, len(params)) for i, p := range params { var txt, required string if p.IsRequired() { required = " (required)" } switch p.(type) { case *KVParam: txt = fmt.Sprintf("-%s <%s>", p.Name(), p.Name()) case *BoolParam: txt = fmt.Sprintf("-%s", p.Name()) case *IdxParam: txt = p.Name() } opttxt[i] = fmt.Sprintf("\t\t%s%s: %s", txt, required, p.Usage()) } out = append(out, "\t"+scmd.Usage()) out = append(out, opttxt...) } return strings.Join(out, "\n") } // SetUsage sets the usage string for the command. Returns the command. func (c *Cmd) SetUsage(usage string) *Cmd { c.usage = usage return c } // SetUsage sets the subcommand's usage string. func (s *SubCmd) SetUsage(usage string) *SubCmd { s.usage = usage return s } // Usage returns the auto-generated usage string for the Command Instance. func (c *CmdInst) Usage() string { return c.cmd.Usage() } func (p *KVParam) Usage() string { return p.usage } func (p *BoolParam) Usage() string { return p.usage } func (p *IdxParam) Usage() string { return p.usage } // SetUsage sets the usage string for the paremeter. Returns the parameter. func (p *KVParam) SetUsage(usage string) *KVParam { p.usage = usage return p } // SetUsage sets the usage string for the paremeter. Returns the parameter. func (p *BoolParam) SetUsage(usage string) *BoolParam { p.usage = usage return p } // SetUsage sets the usage string for the paremeter. Returns the parameter. func (p *IdxParam) SetUsage(usage string) *IdxParam { p.usage = usage return p } func (p *KVParam) SetDefault(def string) *KVParam { p.def = def p.hasdef = true return p } func (p *BoolParam) SetDefault(def bool) *BoolParam { p.def = def p.hasdef = true return p } func (p *IdxParam) SetDefault(def string) *IdxParam { p.def = def p.hasdef = true return p } func (p *KVParam) Key() string { return p.key } func (p *BoolParam) Key() string { return p.key } func (p *IdxParam) Idx() int { return p.idx } func (p *KVParamInst) Key() string { return p.key } func (p *BoolParamInst) Key() string { return p.key } func (p *IdxParamInst) Idx() int { return p.idx } // Name returns the key string. Mostly for use in printing errors, etc. // Implements NamedParam. func (p *KVParam) Name() string { return p.key } // Name returns the key string. Mostly for use in printing errors, etc. // Implements NamedParam. func (p *BoolParam) Name() string { return p.key } // Name returns the name given to the indexed param. // Implements NamedParam. func (p *IdxParam) Name() string { return p.name } func (p *KVParam) IsRequired() bool { return p.required } func (p *BoolParam) IsRequired() bool { return p.required } func (p *IdxParam) IsRequired() bool { return p.required } // Cmd returns the command the parameter belongs to. Panics if no command is attached. func (p *KVParam) Cmd() *Cmd { if p.cmd == nil { panic("Can't call Cmd() on this KVParam because it is not attached to a Cmd!") } return p.cmd } // Cmd returns the command the parameter belongs to. Panics if no command is attached. func (p *BoolParam) Cmd() *Cmd { if p.cmd == nil { panic("Can't call Cmd() on this BoolParam because it is not attached to a Cmd!") } return p.cmd } // Cmd returns the command the parameter belongs to. Panics if no command is attached. func (p *IdxParam) Cmd() *Cmd { if p.cmd == nil { panic("Can't call Cmd() on this IdxParam because it is not attached to a Cmd!") } return p.cmd } func (p *KVParam) SubCmd() *SubCmd { if p.subcmd == nil { panic("Can't call SubCmd() on this KVParam because it is not attached to a SubCmd!") } return p.subcmd } func (p *BoolParam) SubCmd() *SubCmd { if p.subcmd == nil { panic("Can't call SubCmd() on this BoolParam because it is not attached to a SubCmd!") } return p.subcmd } func (p *IdxParam) SubCmd() *SubCmd { if p.subcmd == nil { panic("Can't call SubCmd() on this IdxParam because it is not attached to a SubCmd!") } return p.subcmd } func (p *KVParam) newInst(cmdinst *CmdInst, subcmdinst *SubCmdInst, isdef bool, value string) *KVParamInst { return &KVParamInst{ cmdinst: cmdinst, subcmdinst: subcmdinst, isdef: isdef, param: p, key: p.key, value: value, } } func (p *BoolParam) newInst(cmdinst *CmdInst, subcmdinst *SubCmdInst, isdef bool, value bool) *BoolParamInst { return &BoolParamInst{ cmdinst: cmdinst, subcmdinst: subcmdinst, isdef: isdef, param: p, key: p.key, value: value, } } func (p *IdxParam) newInst(cmdinst *CmdInst, subcmdinst *SubCmdInst, isdef bool, value string) *IdxParamInst { return &IdxParamInst{ cmdinst: cmdinst, subcmdinst: subcmdinst, isdef: isdef, param: p, idx: p.idx, name: p.name, value: value, } } // Cmd returns the command the parameter belongs to. Panics if no command is attached. func (p *KVParamInst) Cmd() *Cmd { if p.param == nil { panic("Can't call Cmd() on this KVParamInst because it is not attached to a KVParam!") } return p.param.Cmd() } // Cmd returns the command the parameter belongs to. Panics if no command is attached. func (p *BoolParamInst) Cmd() *Cmd { if p.param == nil { panic("Can't call Cmd() on this BoolParamInst because it is not attached to a BoolPararm!") } return p.param.Cmd() } // Cmd returns the command the parameter belongs to. Panics if no command is attached. func (p *IdxParamInst) Cmd() *Cmd { if p.param == nil { panic("Can't call Cmd() on this IdxParamInst because it is not attached to a IdxParam!") } return p.param.Cmd() } func (p *KVParamInst) SubCmdInst() *SubCmdInst { if p.subcmdinst == nil { panic("Can't call SubCmdInst() on this KVParamInst because it is not attached to a SubCmdInst!") } return p.subcmdinst } func (p *BoolParamInst) SubCmdInst() *SubCmdInst { if p.subcmdinst == nil { panic("Can't call SubCmdInst() on this BoolParamInst because it is not attached to a SubCmdInst!") } return p.subcmdinst } func (p *IdxParamInst) SubCmdInst() *SubCmdInst { if p.subcmdinst == nil { panic("Can't call SubCmdInst() on this IdxParamInst because it is not attached to a SubCmd!") } return p.subcmdinst } func (p *KVParamInst) Found() bool { return p.found } func (p *BoolParamInst) Found() bool { return p.found } func (p *IdxParamInst) Found() bool { return p.found } func (p *KVParamInst) Required() bool { return p.param.required } func (p *BoolParamInst) Required() bool { return p.param.required } func (p *IdxParamInst) Required() bool { return p.param.required } func (p *KVParamInst) Param() *KVParam { return p.param } func (p *BoolParamInst) Param() *BoolParam { return p.param } func (p *IdxParamInst) Param() *IdxParam { return p.param } // errParam is used to get an interface{} handle to return in errors. // See: RequiredParamNotFound func (p *KVParamInst) errParam() NamedParam { return p.param } // errParam is used to get an interface{} handle to return in errors. func (p *BoolParamInst) errParam() NamedParam { return p.param } // errParam is used to get an interface{} handle to return in errors. func (p *IdxParamInst) errParam() NamedParam { return p.param } // Cmd returns the command it was called on. It does nothing and exists to // make it possible to format chained calls nicely. func (c *Cmd) Cmd() *Cmd { return c } func (s *SubCmd) SubCmd() *SubCmd { return s } func (c *Cmd) Token() string { return c.token } // AddCmd adds a subcommand to the handle and returns the new (sub-)command. func (c *Cmd) AddSubCmd(token string) *SubCmd { sub := SubCmd{} sub.prev = c sub.token = token c.subCmds = append(c.ListSubCmds(), &sub) return &sub } func (c *Cmd) GetKVParam(key string) *KVParam { for _, p := range c._kvparams() { if p.key == key { return p } } panic("BUG: refusing to return nil") } func (c *Cmd) GetBoolParam(key string) *BoolParam { for _, p := range c._boolparams() { if p.key == key { return p } } panic("BUG: refusing to return nil") } // GetIdxParam gets a positional parameter by its index. func (c *Cmd) GetIdxParam(idx int) *IdxParam { ips := c._idxparams() if p, exists := ips[idx]; exists { return p } panic("BUG: refusing to return nil") } func (c *Cmd) HasKVParam(key string) bool { for _, p := range c._kvparams() { if p.key == key { return true } } return false } func (c *Cmd) HasBoolParam(key string) bool { for _, p := range c._boolparams() { if p.key == key { return true } } return false } func (c *Cmd) HasIdxParam(idx int) bool { ips := c._idxparams() _, exists := ips[idx] return exists } // TODO: remove this? func (c *Cmd) SubCmds() []*SubCmd { return c.ListSubCmds() } // GetSubCmd gets a subcommand by its token. Returns nil for no match. func (c *Cmd) GetSubCmd(token string) *SubCmd { for _, s := range c.ListSubCmds() { if s.token == token { return s } } panic("BUG: refusing to return nil") } // parse a list of argv-style strings (0 is always the command name e.g. []string{"prefs"}) // foo bar --baz // foo --bar baz --version // foo bar=baz // foo x=y z=q init --foo baz // TODO: automatic emdash cleanup // TODO: enforce MustSubCmd // TODO: return errors instead of nil/panic func (c *Cmd) Process(argv []string) (*CmdInst, error) { // a hand-coded argument processor that evaluates the provided argv list // against the command definition and returns a CmdInst with all of the // available data parsed and ready to use with CmdInst/ParamInst methods. // the top-level command instance topInst := CmdInst{cmd: c} // no arguments were provided if len(argv) == 1 { if c.mustSubCmd { return nil, SubCmdNotFound{argv: argv} } else { return &topInst, nil } } var curSubCmdInst *SubCmdInst // the current subcommand - changes during parsing var curSubCmdIdx int // the idx the subcommand found in argv var skipNext bool var looseParams []*tmpParamInst // first pass: extract subcommands and parameters for i, arg := range argv[1:] { if skipNext { skipNext = false continue } var key, value, next string var nextExists bool if i+2 < len(argv) { next = argv[i+2] nextExists = true } else { nextExists = false } if c.HasIdxParam(i - 1) { // top-level command has positional parameters pi := IdxParamInst{ cmdinst: &topInst, found: true, idx: i - 1, param: c.GetIdxParam(i - 1), value: arg, } topInst.appendIdxParamInst(&pi) } else if curSubCmdInst != nil && curSubCmdInst.HasIdxParam(0) { // subcommand has positional parameters paramIdx := i - curSubCmdIdx - 1 pi := IdxParamInst{ cmdinst: &topInst, subcmdinst: curSubCmdInst, found: true, idx: paramIdx, param: curSubCmdInst.GetIdxParam(paramIdx), value: arg, } curSubCmdInst.appendIdxParamInst(&pi) } else if strings.Contains(arg, "=") { // looks like a key=value or --key=value parameter // could be --foo=bar but all that matters is the "foo" // could be --foo=true for BoolParam and that's fine too kv := strings.SplitN(arg, "=", 2) key = strings.TrimLeft(kv[0], "-") value = kv[1] // falls through, further processing below this if block... } else if looksLikeParam(arg) { // looks like a parameter // e.g. --foo bar -f bar key = strings.TrimLeft(arg, "-") // TODO: this handles many instances of boolean flags that indicate true // simply by being present. There are likely some edge cases where this // doesn't work because the following param doesn't look like a param // then again maybe it's no big deal... if nextExists && !looksLikeParam(next) { value = next skipNext = true } // falls through, further processing below this if block... } else if curSubCmdInst == nil && c.HasSubCmdToken(arg) { // the first subcommand - the "foo" in "!command foo bar --baz" for _, sc := range topInst.cmd.ListSubCmds() { if sc.token == arg { sci := SubCmdInst{subCmd: sc} sci.cmd = c curSubCmdInst = &sci topInst.subCmdInst = &sci break } } continue // processed a subcommand, move onto the next arg } else if curSubCmdInst != nil && curSubCmdInst.subCmd.HasSubCmdToken(arg) { // sub-subcommands - the "bar" or "blargh" in "!command foo bar blargh --baz" for _, sc := range curSubCmdInst.subCmd.ListSubCmds() { if arg == sc.token { sci := SubCmdInst{subCmd: sc} sci.cmd = c // point the current subcommand to the new one curSubCmdInst.subCmdInst = &sci // advance "current" to the new subcommand curSubCmdInst = &sci // set the index where the subcommand was discovered for use // in extracting postitional parameters (above) curSubCmdIdx = i } } continue // processed a subcommand, move onto the next arg } else { // leftover/unrecognized args go in .remainder topInst.remainder = append(topInst.Remainder(), arg) continue } pinst := tmpParamInst{ key: key, arg: arg, value: value, found: true, cmd: c, cmdinst: &topInst, } // the most recent subcommand seen gets the first shot at a parameter // !foo --bar baz --bar // !foo baz --bar // !foo --bar baz if curSubCmdInst != nil && curSubCmdInst.subCmd.HasKeyParam(key) { // the parameter belongs to the subcommand pinst.subcmd = curSubCmdInst.subCmd pinst.subcmdinst = curSubCmdInst pinst.attachKeyParam(curSubCmdInst) } else if c.HasKeyParam(key) { // the parameter belongs to the command pinst.attachKeyParam(&topInst) } else { // store (likely) out-of-order parameters to process after all args & // subcommands are discovered looseParams = append(looseParams, &pinst) } } if c.mustSubCmd && topInst.subCmdInst == nil { return nil, SubCmdNotFound{argv: argv} } // find a home for out-of-order parameters, panic if that fails since it's a bug for _, linst := range looseParams { if topInst.subCmdInst == nil { panic("found out-of-order params but no subcommand! Maybe bug, maybe I need to put a better error here...") } linst.findAndAttachKeyParam(topInst.subCmdInst) } // now that all the parameters have been parsed and attached to a *cmdInst, // find required parameters with defaults and create param instances // or return an error when a required param isn't found // check for required parameters on the command for _, p := range c.kvparams { if p.required && !topInst.HasKVParamInst(p.Key()) { if p.hasdef { pinst := p.newInst(&topInst, nil, true, p.def) topInst.appendKVParamInst(pinst) } else { return nil, RequiredParamNotFound{p} } } } for _, p := range c.boolparams { if p.required && !topInst.HasBoolParamInst(p.Key()) { if p.hasdef { pinst := p.newInst(&topInst, nil, true, p.def) topInst.appendBoolParamInst(pinst) } else { return nil, RequiredParamNotFound{p} } } } for idx, p := range c.idxparams { if p.required && !topInst.HasIdxParamInst(idx) { if p.hasdef { pinst := p.newInst(&topInst, nil, true, p.def) topInst.appendIdxParamInst(pinst) } else { return nil, RequiredParamNotFound{p} } } } // check subcommands one by one for _, sci := range topInst.listSubCmdInst() { // go over each parameter and see if it's present for _, p := range sci.subCmd._kvparams() { // see if it's required and was not found if p.required && !sci.HasKVParamInst(p.Key()) { // if there is a default, use it if p.hasdef { pinst := p.newInst(&topInst, sci, true, p.def) sci.appendKVParamInst(pinst) } else { // required parameter was missing, return an error return nil, RequiredParamNotFound{p} } } } for _, p := range sci.subCmd._boolparams() { if p.required && !sci.HasBoolParamInst(p.Key()) { if p.hasdef { pinst := p.newInst(&topInst, sci, true, p.def) sci.appendBoolParamInst(pinst) } else { return nil, RequiredParamNotFound{p} } } } for _, p := range sci.subCmd._idxparams() { if p.required && !sci.HasIdxParamInst(p.Idx()) { if p.hasdef { pinst := p.newInst(&topInst, sci, true, p.def) sci.appendIdxParamInst(pinst) } else { return nil, RequiredParamNotFound{p} } } } } return &topInst, nil } // looksLikeBool checks to see if the provided value contains "true" or "false" // in any case combination. func looksLikeBool(val string) bool { lcval := strings.ToLower(val) if strings.Contains(lcval, "true") { return true } if strings.Contains(lcval, "false") { return true } return false } // looksLikeParam returns true if there is a leading - or an = in the string. func looksLikeParam(key string) bool { if strings.HasPrefix(key, "-") { return true } else if strings.Contains(key, "=") { return true } else { return false } } func (tmp *tmpParamInst) attachKeyParam(whatever cmdorsubcmd) { var haskvparam, hasboolparam bool switch whatever.(type) { case *SubCmdInst: i := whatever.(*SubCmdInst) haskvparam = i.subCmd.HasKVParam(tmp.key) hasboolparam = i.subCmd.HasBoolParam(tmp.key) case *CmdInst: i := whatever.(*CmdInst) haskvparam = i.cmd.HasKVParam(tmp.key) hasboolparam = i.cmd.HasBoolParam(tmp.key) } if haskvparam { p := whatever.GetKVParam(tmp.key) pi := KVParamInst{ arg: tmp.arg, cmdinst: tmp.cmdinst, found: tmp.found, key: tmp.key, param: p, subcmdinst: tmp.subcmdinst, value: tmp.value, } switch whatever.(type) { case *CmdInst: ci := whatever.(*CmdInst) ci.kvparaminsts = append(ci.ListKVParamInsts(), &pi) case *SubCmdInst: sci := whatever.(*SubCmdInst) sci.kvparaminsts = append(sci.ListKVParamInsts(), &pi) } } else if hasboolparam { var val bool var err error // a provided flag with an empty value is true if tmp.found && tmp.value == "" { val = true } else { val, err = strconv.ParseBool(tmp.value) if err != nil { log.Panicf("invalid bool value %q for key %q", tmp.value, tmp.key) } } p := whatever.GetBoolParam(tmp.key) pi := BoolParamInst{ arg: tmp.arg, cmdinst: tmp.cmdinst, found: tmp.found, key: tmp.key, param: p, subcmdinst: tmp.subcmdinst, value: val, } switch whatever.(type) { case *CmdInst: ci := whatever.(*CmdInst) ci.boolparaminsts = append(ci.ListBoolParamInsts(), &pi) case *SubCmdInst: sci := whatever.(*SubCmdInst) sci.boolparaminsts = append(sci.ListBoolParamInsts(), &pi) } } else { log.Panicf("BUG: arg %q does not have a matching parameter for key %q", tmp.arg, tmp.key) } } func (tmp *tmpParamInst) findAndAttachKeyParam(sub *SubCmdInst) { if sub.HasBoolParam(tmp.key) || sub.HasKVParam(tmp.key) { tmp.attachKeyParam(sub) } else if sub.subCmdInst != nil { tmp.findAndAttachKeyParam(sub.subCmdInst) } } // listSubCmdInst returns a list of subcommand instances from the command line // in natural order, e.g. "cmd sub1 -f sub2 -x sub3 -y" => [sub1, sub2, sub3] func (c *CmdInst) listSubCmdInst() []*SubCmdInst { var i int out := make([]*SubCmdInst, 0) if c.subCmdInst == nil { return out } out = append(out, c.subCmdInst) for { if out[i].subCmdInst != nil { out = append(out, out[i].subCmdInst) i++ } else { break } } return out } // HasSubCmdToken returns whether or not the proivded token is defined as a subcommand. func (c *Cmd) HasSubCmdToken(token string) bool { if c == nil { return false } for _, sc := range c.ListSubCmds() { if token == sc.token { return true } } return false } // HasKeyParam returns true if there are any parameters defined with // the provided key of either key type (bool or kv). func (c *Cmd) HasKeyParam(key string) bool { if c == nil { return false } for _, p := range c._boolparams() { if key == p.key { return true } } for _, p := range c._kvparams() { if key == p.key { return true } } return false } // ListNamedParams returns a list of all parameters via the interface NamedParam. // Mainly for use in printing options, etc.. func (c *Cmd) ListNamedParams() []NamedParam { out := make([]NamedParam, 0) for _, p := range c._boolparams() { out = append(out, p) } for _, p := range c._kvparams() { out = append(out, p) } // use 2 passes to append idx parameters in order ipm := c._idxparams() ips := make([]*IdxParam, len(ipm)) for _, p := range c._idxparams() { ips[p.idx] = p } for _, p := range ips { out = append(out, p) } return out } // SubCmdToken returns the subcommand's token string. Returns empty string // if there is no subcommand. func (c *CmdInst) SubCmdToken() string { if c.subCmdInst != nil { return c.subCmdInst.subCmd.token } return "" } func (c *SubCmdInst) SubCmdToken() string { if c.subCmdInst != nil { return c.subCmdInst.subCmd.token } return "" } func (c *CmdInst) SubCmdInst() *SubCmdInst { return c.subCmdInst } func (c *CmdInst) HasKVParamInst(key string) bool { for _, p := range c.ListKVParamInsts() { if p.key == key { return true } } return false } func (c *CmdInst) HasKVParam(key string) bool { return c.cmd.HasKVParam(key) } func (c *SubCmdInst) HasKVParam(key string) bool { return c.subCmd.HasKVParam(key) } func (c *CmdInst) HasBoolParamInst(key string) bool { for _, p := range c.ListBoolParamInsts() { if p.key == key { return true } } return false } func (c *CmdInst) HasBoolParam(key string) bool { return c.cmd.HasBoolParam(key) } func (c *CmdInst) HasIdxParamInst(idx int) bool { ipis := c.mapIdxParamInsts() _, exists := ipis[idx] return exists } func (c *CmdInst) HasIdxParam(idx int) bool { return c.cmd.HasIdxParam(idx) } func (c *SubCmdInst) HasIdxParam(idx int) bool { return c.subCmd.HasIdxParam(idx) } // GetKVParamInst gets a key/value parameter instance by its key. func (c *CmdInst) GetKVParamInst(key string) *KVParamInst { for _, p := range c.ListKVParamInsts() { if p.key == key { return p } } if c.HasKVParam(key) { // not provided, return empty value with found: false return &KVParamInst{ param: c.GetKVParam(key), key: key, cmdinst: c, } } else { panic("BUG: invalid KVParam key '" + key + "'") } } // GetKVParamInst gets a key/value parameter instance by its key. func (c *SubCmdInst) GetKVParamInst(key string) *KVParamInst { for _, p := range c.ListKVParamInsts() { if p.key == key { return p } } if c.HasKVParam(key) { // not provided, return empty value with found: false return &KVParamInst{ param: c.GetKVParam(key), key: key, cmdinst: &c.CmdInst, subcmdinst: c, } } else { panic("BUG: invalid KVParam key '" + key + "'") } } func (c *CmdInst) GetKVParam(key string) *KVParam { for _, p := range c.cmd._kvparams() { if p.key == key { return p } } panic("BUG: refusing to return nil") } func (c *SubCmdInst) GetKVParam(key string) *KVParam { for _, p := range c.subCmd._kvparams() { if p.key == key { return p } } panic("BUG: refusing to return nil" + key) } // GetBoolParamInst gets a key/value parameter instance by its key. func (c *CmdInst) GetBoolParamInst(key string) *BoolParamInst { for _, p := range c.ListBoolParamInsts() { if p.key == key { return p } } // not provided, return empty value with found: false if c.HasBoolParam(key) { return &BoolParamInst{ param: c.GetBoolParam(key), key: key, cmdinst: c, } } else { panic("BUG: invalid BoolParam key '" + key + "'") } } // GetBoolParamInst gets a key/value parameter instance by its key. func (c *SubCmdInst) GetBoolParamInst(key string) *BoolParamInst { for _, p := range c.ListBoolParamInsts() { if p.key == key { return p } } // not provided, return empty value with found: false if c.HasBoolParam(key) { return &BoolParamInst{ param: c.GetBoolParam(key), key: key, cmdinst: &c.CmdInst, subcmdinst: c, } } else { panic("BUG: invalid BoolParam key '" + key + "'") } } func (c *CmdInst) GetBoolParam(key string) *BoolParam { for _, p := range c.cmd._boolparams() { if p.key == key { return p } } panic("BUG: refusing to return nil") } func (c *SubCmdInst) GetBoolParam(key string) *BoolParam { for _, p := range c.subCmd._boolparams() { if p.key == key { return p } } panic("BUG: refusing to return nil") } // GetIdxParamInst gets a positional parameter instance by its index. func (c *CmdInst) GetIdxParamInst(idx int) *IdxParamInst { ipis := c.mapIdxParamInsts() if p, exists := ipis[idx]; exists { return p } // not provided, return empty value with found: false if c.HasIdxParam(idx) { return &IdxParamInst{ param: c.GetIdxParam(idx), idx: idx, cmdinst: c, } } else { panic(fmt.Sprintf("BUG: invalid IdxParam index: %d", idx)) } } // GetIdxParamInst gets an indexed parameter instance by its index. func (c *SubCmdInst) GetIdxParamInst(idx int) *IdxParamInst { ipis := c.mapIdxParamInsts() if p, exists := ipis[idx]; exists { return p } // not provided, return empty value with found: false if c.HasIdxParam(idx) { return &IdxParamInst{ param: c.GetIdxParam(idx), idx: idx, cmdinst: &c.CmdInst, subcmdinst: c, } } else { panic(fmt.Sprintf("BUG: invalid IdxParam index: %d", idx)) } } // GetIdxParamInsByNamet gets an indexed parameter instance by its name. func (c *CmdInst) GetIdxParamInstByName(name string) *IdxParamInst { ips := c.cmd._idxparams() for _, p := range ips { if p.name == name { return c.GetIdxParamInst(p.idx) } } panic("BUG: No indexed parameter with name: " + name) } // GetIdxParamInstByName gets an indexed parameter instance by its name. func (c *SubCmdInst) GetIdxParamInstByName(name string) *IdxParamInst { ips := c.subCmd._idxparams() for _, p := range ips { if p.name == name { return c.GetIdxParamInst(p.idx) } } panic("BUG: No indexed parameter with name: " + name) } func (c *CmdInst) GetIdxParam(idx int) *IdxParam { ips := c.cmd._idxparams() if p, exists := ips[idx]; exists { return p } panic("BUG: refusing to return nil") } func (c *SubCmdInst) GetIdxParam(idx int) *IdxParam { ips := c.subCmd._idxparams() if p, exists := ips[idx]; exists { return p } panic("BUG: refusing to return nil") } func (c *CmdInst) appendKVParamInst(pi *KVParamInst) { c.kvparaminsts = append(c.ListKVParamInsts(), pi) } func (c *CmdInst) appendBoolParamInst(pi *BoolParamInst) { c.boolparaminsts = append(c.ListBoolParamInsts(), pi) } func (c *CmdInst) appendIdxParamInst(pi *IdxParamInst) { ipis := c.mapIdxParamInsts() ipis[pi.idx] = pi } // ListKVParamInsts initializes the kvparaminsts list on the fly and returns it. func (c *CmdInst) ListKVParamInsts() []*KVParamInst { if c.kvparaminsts == nil { c.kvparaminsts = make([]*KVParamInst, 0) } return c.kvparaminsts } // ListBoolParamInsts initializes the boolparaminsts list on the fly and returns it. func (c *CmdInst) ListBoolParamInsts() []*BoolParamInst { if c.boolparaminsts == nil { c.boolparaminsts = make([]*BoolParamInst, 0) } return c.boolparaminsts } // mapIdxParamInsts initializes the idxparaminsts list on the fly and returns it. func (c *CmdInst) mapIdxParamInsts() map[int]*IdxParamInst { if c.idxparaminsts == nil { c.idxparaminsts = make(map[int]*IdxParamInst) } return c.idxparaminsts } func (c *CmdInst) ListIdxParamInsts() []*IdxParamInst { ipis := c.mapIdxParamInsts() out := make([]*IdxParamInst, len(ipis)) for i, pi := range ipis { out[i] = pi } return out } // Remainder initializes the remainder list on the fly and returns it. func (c *CmdInst) Remainder() []string { if c.remainder == nil { c.remainder = make([]string, 0) } return c.remainder } // Aliases initializes the aliases list on the fly and returns it. func (p *KVParam) Aliases() []string { if p.aliases == nil { p.aliases = make([]string, 0) } return p.aliases } func (p *KVParamInst) Value() string { return p.value } func (p *BoolParamInst) Value() bool { return p.value } func (p *IdxParamInst) Value() string { return p.value } // Name returns the key string. Mostly for use in printing errors, etc. // Implements NamedParam. func (p *KVParamInst) Name() string { return p.key } // Name returns the key string. Mostly for use in printing errors, etc. // Implements NamedParam. func (p *BoolParamInst) Name() string { return p.key } // Name returns the name given to the indexed param. // Implements NamedParam. func (p *IdxParamInst) Name() string { return p.name } // String returns the value as a string. func (p *KVParamInst) String() (string, error) { if !p.found && p.param.required { return "", RequiredParamNotFound{p.param} } return p.value, nil } // String returns the value as a string. func (p *BoolParamInst) String() (string, error) { if !p.found && p.param.required { return "", RequiredParamNotFound{p.param} } if p.value { return "true", nil } else { return "false", nil } } // String returns the value as a string. func (p *IdxParamInst) String() (string, error) { if !p.found && p.param.required { return "", RequiredParamNotFound{p.param} } return p.value, nil } // intParam returns the value as an int. If the param is required and it was // not set, RequiredParamNotFound is returned. Additionally, any errors in // conversion are returned. func intParam(p stringValuedParamInst) (int, error) { if !p.Found() { if p.Required() { return 0, RequiredParamNotFound{p.errParam()} } else { return 0, nil } } val, err := strconv.ParseInt(p.Value(), 10, 64) return int(val), err // warning: doesn't handle overflow } func (p *KVParamInst) Int() (int, error) { return intParam(p) } func (p *IdxParamInst) Int() (int, error) { return intParam(p) } // Float returns the value of the parameter as a float. If the value cannot // be converted, an error will be returned. See: strconv.ParseFloat func floatParam(p stringValuedParamInst) (float64, error) { if !p.Found() { if p.Required() { return 0, RequiredParamNotFound{p.errParam()} } else { return 0, nil } } return strconv.ParseFloat(p.Value(), 64) } func (p *KVParamInst) Float() (float64, error) { return floatParam(p) } func (p *IdxParamInst) Float() (float64, error) { return floatParam(p) } // Bool returns the value of the parameter as a bool. // If the value is required and not set, returns RequiredParamNotFound. // If the value cannot be converted, an error will be returned. // See: strconv.ParseBool func boolParam(p stringValuedParamInst) (bool, error) { if !p.Found() { if p.Required() { return false, RequiredParamNotFound{p.errParam()} } else { return false, nil } } stripped := strings.Trim(p.Value(), `'"`) return strconv.ParseBool(stripped) } func (p *KVParamInst) Bool() (bool, error) { return boolParam(p) } func (p *IdxParamInst) Bool() (bool, error) { return boolParam(p) } // Duration returns the value of the parameter as a Go time.Duration. // Day and Week (e.g. "1w", "1d") are converted to 168 and 24 hours respectively. // If the value is required and not set, returns RequiredParamNotFound. // If the value cannot be converted, an error will be returned. // See: time.ParseDuration func durationParam(p stringValuedParamInst) (time.Duration, error) { duration := p.Value() empty := time.Duration(0) if !p.Found() { if p.Required() { return empty, RequiredParamNotFound{p.errParam()} } else { return empty, nil } } if strings.HasSuffix(duration, "w") { weeks, err := strconv.Atoi(strings.TrimSuffix(duration, "w")) if err != nil { return empty, fmt.Errorf("Could not convert duration %q: %s", duration, err) } return time.Hour * time.Duration(weeks*24*7), nil } else if strings.HasSuffix(duration, "d") { days, err := strconv.Atoi(strings.TrimSuffix(duration, "d")) if err != nil { return empty, fmt.Errorf("Could not convert duration %q: %s", duration, err) } return time.Hour * time.Duration(days*24), nil } else { return time.ParseDuration(duration) } } func (p *KVParamInst) Duration() (time.Duration, error) { return durationParam(p) } func (p *IdxParamInst) Duration() (time.Duration, error) { return durationParam(p) } // Time returns the value of the parameter as a Go time.Time. // Many formats are attempted before giving up. // If the value is required and not set, returns RequiredParamNotFound. // If the value cannot be converted, an error will be returned. // See: TimeFormats in this package // See: time.ParseDuration func timeParam(p stringValuedParamInst) (time.Time, error) { if !p.Found() { if p.Required() { return time.Time{}, RequiredParamNotFound{p.errParam()} } else { return time.Time{}, nil } } t := p.Value() // convert Z suffix to +00:00 if strings.HasSuffix(t, "Z") { t = strings.TrimSuffix(t, "Z") + "+00:00" } // try all of the formats for _, fmt := range TimeFormats { out, err := time.Parse(fmt, t) if err != nil { continue } else { return out, nil } } return time.Time{}, UnsupportedTimeFormatError{t} } func (p *KVParamInst) Time() (time.Time, error) { return timeParam(p) } func (p *IdxParamInst) Time() (time.Time, error) { return timeParam(p) } // MustString returns the value as a string. If it was required/not-set, // panic ensues. Empty string may be returned for not-required+not-set. func (p *KVParamInst) MustString() string { out, err := p.String() if p.Required() && err != nil { panic(err) } return out } func (p *IdxParamInst) MustString() string { out, err := p.String() if p.Required() && err != nil { panic(err) } return out } // DefString returns the value as a string. Rules: // If the param is required and it was not set, return the provided default. // If the param is not required and it was not set, return the empty string. // If the param is set and the value is "*", return the provided default. // If the param is set, return the value. func defStringParam(p stringValuedParamInst, def string) string { if !p.Found() { if p.Required() { // not set, required return def } else { // not set, not required return "" } } else if p.Value() == "*" { return def } out, err := p.String() if err != nil { return def } return out } func (p *KVParamInst) DefString(def string) string { return defStringParam(p, def) } func (p *IdxParamInst) DefString(def string) string { return defStringParam(p, def) } // DefInt returns the value as an int. See DefString for the rules. func defIntParam(p stringValuedParamInst, def int) int { if !p.Found() { if p.Required() { return def } else { return 0 } } else if p.Value() == "*" { return def } out, err := p.Int() if err != nil { return def } return out } func (p *KVParamInst) DefInt(def int) int { return defIntParam(p, def) } func (p *IdxParamInst) DefInt(def int) int { return defIntParam(p, def) } // DefFloat returns the value as a float. See DefString for the rules. func defFloatParam(p stringValuedParamInst, def float64) float64 { if !p.Found() { if p.Required() { return def } else { return 0 } } else if p.Value() == "*" { return def } out, err := p.Float() if err != nil { return def } return out } // DefBool returns the value as a bool. See DefString for the rules. func defBoolParam(p stringValuedParamInst, def bool) bool { if !p.Found() { if p.Required() { return def } else { return false } } else if p.Value() == "*" { return def } out, err := p.Bool() if err != nil { return def } return out } ================================================ FILE: hal/cmd_test.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "strings" "testing" ) func TestCmd(t *testing.T) { // example 1 - smoke test oc := NewCmd("oncall", false). SetUsage("search Pagerduty escalation policies for a string") oc.AddSubCmd("cache-status") oc.AddSubCmd("cache-interval").AddIdxParam(0, "interval", true) oc.AddSubCmd("help").AddAlias("h") oc.GetSubCmd("cache-status").SetUsage("check the status of the background caching job") oc.GetSubCmd("cache-interval").SetUsage("set the background caching job interval") var res *CmdInst var err error // make sure a command with no args doesn't blow up res, err = oc.Process([]string{"!oncall"}) if err != nil { t.Fail() } res, err = oc.Process([]string{"!oncall", "help"}) if err != nil { t.Fail() } res, err = oc.Process([]string{"!oncall", "h"}) if err != nil { t.Error(err) t.Fail() } res, err = oc.Process([]string{"!oncall", "sre"}) if len(res.Remainder()) != 1 || res.Remainder()[0] != "sre" { t.Fail() } res, err = oc.Process([]string{"!oncall", "cache-status"}) if err != nil { t.Error(err) t.Fail() } if res.SubCmdToken() != "cache-status" { t.Fail() } res, err = oc.Process([]string{"!oncall", "cache-interval", "1h"}) if err != nil { t.Error(err) t.Fail() } if res.SubCmdToken() != "cache-interval" { t.Fail() } // example 2 // Alias: requiring explicit aliases instead of guessing seems right pc := NewCmd("prefs", true) pc.AddSubCmd("set"). SetUsage("set a pref"). SubCmd().AddKVParam("key", true).AddAlias("k").SetUsage("ohai!"). SubCmd().AddKVParam("value", true).AddAlias("v"). SubCmd().AddKVParam("room", false).AddAlias("r"). SubCmd().AddKVParam("user", false).AddAlias("u"). SubCmd().AddKVParam("broker", false).AddAlias("b") pc.AddSubCmd("get"). SubCmd().AddKVParam("key", true).AddAlias("k"). SubCmd().AddKVParam("value", true).AddAlias("v"). SubCmd().AddKVParam("room", false).AddAlias("r"). SubCmd().AddKVParam("user", false).AddAlias("u").SetDefault("*"). SubCmd().AddKVParam("broker", false).AddAlias("b") pc.AddSubCmd("rm").AddIdxParam(0, "id", true) argv2 := strings.Split("prefs set --room * --user foo --broker console --key ohai --value nevermind", " ") res, err = pc.Process(argv2) if err != nil { t.Error(err) t.Fail() } if len(res.Remainder()) != 0 { t.Error("There should not be any remainder") } if res.SubCmdToken() != "set" { t.Errorf("wrong subcommand. Expected %q, got %q", "set", res.SubCmdToken()) } if res.SubCmdInst() == nil { t.Error("result.SubCmdInst is nil when it should be an instance for 'set'") t.FailNow() } subcmd := res.SubCmdInst() if subcmd.GetKVParamInst("room").MustString() != "*" { t.Errorf("wrong room, expected *, got %q", subcmd.GetKVParamInst("room").MustString()) } if subcmd.GetKVParamInst("key").MustString() != "ohai" { t.Errorf("wrong key, expected 'ohai', got %q", subcmd.GetKVParamInst("key").MustString()) } if subcmd.GetKVParamInst("value").MustString() != "nevermind" { t.Errorf("wrong value, expected 'nevermind', got %q", subcmd.GetKVParamInst("value").MustString()) } // check that defaults are working dval := "1234" rds := subcmd.GetKVParamInst("room").DefString(dval) if rds != dval { t.Errorf("DefString returned %q, expected %q", rds, dval) } irds := subcmd.GetKVParamInst("room").DefInt(999) if irds != 999 { t.Errorf("DefString returned %d, expected 999", irds) } // again with out-of-order parameters argv3 := strings.Split("prefs --user bob --key testing get --value lol", " ") res, err = pc.Process(argv3) if err != nil { t.Error(err) t.Fail() } if len(res.Remainder()) != 0 { t.Error("There should not be any remainder") } if res.SubCmdToken() != "get" { t.Errorf("wrong subcommand. Expected 'get', got %q", res.SubCmdToken()) } if res.SubCmdInst() == nil { t.Error("result.SubCmdInst is nil when it should be an instance for 'get'") t.FailNow() } subcmd = res.SubCmdInst() kvpi := subcmd.GetKVParamInst("key") if kvpi == nil { t.Error("BUG: subcmd.GetKVParamInst('key') returned nil") t.FailNow() } if kvpi.MustString() != "testing" { t.Errorf("wrong key, expected 'testing', got %q", subcmd.GetKVParamInst("key").MustString()) } argv4 := []string{"!prefs", "rm", "4"} res, err = pc.Process(argv4) if err != nil { t.Error(err) t.Fail() } if res.SubCmdToken() != "rm" { t.Errorf("Expected rm, got %q", res.SubCmdToken()) } pp := res.SubCmdInst().GetIdxParamInst(0) if pp.Value() != "4" { t.Errorf("wrong value from positional parameter. got %d, expected 4", pp.idx) } dc := NewCmd("dc", false) dc.AddKVParam("dc_required_kvparam_with_default", true).SetDefault("this is the default") sdc := dc.AddSubCmd("test") sdc.AddKVParam("kvparam_with_default", false).SetDefault("this is the default 1") sdc.AddKVParam("required_kvparam_with_default", true).SetDefault("this is the default 2") sdc.AddKVParam("required_kvparam_without_default", true) sdc.AddBoolParam("boolparam_with_default", false).SetDefault(true) sdc.AddBoolParam("required_boolparam_with_default", true).SetDefault(false) sdc.AddBoolParam("required_boolparam_without_default", true) res, err = dc.Process([]string{"dc"}) if err != nil { t.Errorf("command should parse ok with no arguments: %s", err) t.Fail() } res, err = dc.Process([]string{"dc", "--dc_required_kvparam_with_default", "whatever"}) if err != nil { t.Fail() } res, err = dc.Process([]string{"dc", "test"}) if res != nil || err == nil { t.Errorf("subcommand should NOT parse ok with no arguments: %s", err) t.Fail() } res, err = dc.Process([]string{"dc", "test", "required_kvparam_without_default=yes", "--required_boolparam_without_default"}) if err != nil { t.Fail() } } ================================================ FILE: hal/counter.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( dbsql "database/sql" ) const CounterTable = ` CREATE TABLE IF NOT EXISTS counter ( pkey VARCHAR(191) NOT NULL, value int, ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(pkey) )` func GetCounter(key string) (value int, err error) { db := SqlDB() SqlInit(CounterTable) sql := "SELECT value FROM counter WHERE pkey=?" err = db.QueryRow(sql, key).Scan(&value) if err == dbsql.ErrNoRows { return 0, nil } else if err != nil { log.Printf("GetCounter query failed: %s", err) return 0, err } return value, nil } func SetCounter(key string, value int) error { db := SqlDB() SqlInit(CounterTable) sql := `INSERT INTO counter (pkey,value) VALUES (?,?) ON DUPLICATE KEY UPDATE value=?` _, err := db.Exec(sql, key, value, value) if err != nil { log.Printf("SetCounter upsert failed: %s", err) } return err } func IncrementCounter(key string) error { db := SqlDB() SqlInit(CounterTable) sql := `INSERT INTO counter (pkey,value) VALUES (?,1) ON DUPLICATE KEY UPDATE value=value+1` _, err := db.Exec(sql, key) if err != nil { log.Printf("IncrementCounter query failed: %s", err) } return err } func DecrementCounter(key string) error { db := SqlDB() SqlInit(CounterTable) sql := `INSERT INTO counter (pkey,value) VALUES (?,-1) ON DUPLICATE KEY UPDATE value=value-1` _, err := db.Exec(sql, key) if err != nil { log.Printf("DecrementCounter query failed: %s", err) } return err } ================================================ FILE: hal/directory.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "sync" "github.com/juju/errors" ) // directory is a simple graph of directory-style information that can be linked // and queried on those links. Goals: // 1. avoid coupling between plugins // 2. make it easy to share data between plugins // 3. make it easier to link data across various systems (e.g. Pagerduty, company directory, Slack) type directory struct { initOnce sync.Once } const DirNodeTable = ` CREATE TABLE IF NOT EXISTS dir_node ( pkey VARCHAR(191) NOT NULL, kind VARCHAR(191) NOT NULL, ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(pkey, kind) )` type DirNode struct { Key string `json:"key"` Kind string `json:"kind"` Ts int `json:"ts"` } const DirNodeAttrTable = ` CREATE TABLE IF NOT EXISTS dir_node_attr ( pkey VARCHAR(191) NOT NULL, kind VARCHAR(191) NOT NULL, attr VARCHAR(191) NOT NULL, value MEDIUMTEXT, ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(pkey, kind, attr), INDEX (pkey, kind), FOREIGN KEY (pkey, kind) REFERENCES dir_node(pkey, kind) ON UPDATE CASCADE )` type DirNodeAttr struct { Key string `json:"key"` Kind string `json:"kind"` Attr string `json:"attr"` Value string `json:"value"` Ts int `json:"ts"` } const DirEdgeTable = ` CREATE TABLE IF NOT EXISTS dir_edge ( keyA VARCHAR(191) NOT NULL, kindA VARCHAR(191) NOT NULL, keyB VARCHAR(191) NOT NULL, kindB VARCHAR(191) NOT NULL, ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(keyA, kindA, keyB, kindB), INDEX (keyA, kindA), INDEX (keyB, kindB), FOREIGN KEY (keyA, kindA) REFERENCES dir_node(pkey, kind) ON UPDATE CASCADE, FOREIGN KEY (keyB, kindB) REFERENCES dir_node(pkey, kind) ON UPDATE CASCADE )` type DirEdge struct { KeyA string `json:"key_a"` KindA string `json:"kind_a"` KeyB string `json:"key_b"` KindB string `json:"kind_b"` Ts int `json:"ts"` } var dirSingleton directory func Directory() *directory { dirSingleton.initOnce.Do(func() { SqlInit(DirNodeTable) SqlInit(DirNodeAttrTable) SqlInit(DirEdgeTable) }) return &dirSingleton } func (dir *directory) exec(sql string, params ...interface{}) error { db := SqlDB() _, err := db.Exec(sql, params...) if err != nil { return errors.Annotatef(err, "SQL: %q, Values: %+q", sql, params) } return nil } func (dir *directory) query(sql string, params ...interface{}) ([][]string, error) { db := SqlDB() out := make([][]string, 0) rows, err := db.Query(sql, params...) if err != nil { return out, errors.Annotatef(err, "SQL: %q, Values: %+q", sql, params) } defer rows.Close() cols, err := rows.Columns() if err != nil { return out, errors.Annotate(err, "rows.Columns()") } for rows.Next() { irow := make([]interface{}, len(cols)) row := make([]string, len(irow)) for i, _ := range irow { irow[i] = &row[i] } err := rows.Scan(irow...) if err != nil { return out, errors.Annotate(err, "rows.Scan()") } out = append(out, row) } return out, nil } // Put adds/updates a node for the given key/attr and creates edges for // the attributes where possible. func (dir *directory) Put(key, kind string, attrs map[string]string, edgeAttrs []string) error { err := dir.PutNode(key, kind) if err != nil { return err } if attrs == nil { return errors.NotValidf("attrs cannot be nil") } for attr, value := range attrs { err := dir.PutNodeAttr(key, kind, attr, value) if err != nil { return err } } // experimental: use the provided list of keys to try to create edges based on attributes for _, ea := range edgeAttrs { if value, exists := attrs[ea]; exists { neighbors, err := dir.GetAttrNodes(ea, value) if err != nil { return errors.Annotate(err, "GetAttrNodes failed") } for _, neighbor := range neighbors { dir.PutEdge(key, kind, neighbor[0], neighbor[1]) } } } return nil } func (dir *directory) PutNode(key, kind string) error { sql := `INSERT INTO dir_node (pkey, kind) VALUES (?, ?) ON DUPLICATE KEY UPDATE ts=NOW()` return dir.exec(sql, key, kind) } func (dir *directory) HasNode(key, kind string) (bool, error) { sql := `SELECT pkey, kind FROM dir_node WHERE pkey=? AND kind=?` data, err := dir.query(sql, &key, &kind) if err != nil { return false, err } if len(data) > 0 { return true, nil } return false, nil } func (dir *directory) DelNode(key, kind string) error { sql := `DELETE FROM dir_node WHERE key=? AND kind=?` return dir.exec(sql, key, kind) } func (dir *directory) PutNodeAttr(key, kind, attr, value string) error { sql := `INSERT INTO dir_node_attr (pkey, kind, attr, value) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE value=?, ts=NOW()` return dir.exec(sql, key, kind, attr, value, value) } // GetAttrNodes takes an attribute and value and returns a list of nodes // that have (exactly) matching attributes. func (dir *directory) GetAttrNodes(attr, value string) ([][2]string, error) { out := make([][2]string, 0) sql := `SELECT pkey, kind FROM dir_node_attr WHERE attr=? AND value=? GROUP BY pkey, kind` data, err := dir.query(sql, &attr, &value) if err != nil { return out, err } for _, item := range data { // item is []string, return must be [2]string out = append(out, [2]string{item[0], item[1]}) } return out, nil } func (dir *directory) HasEdge(keyA, kindA, keyB, kindB string) (bool, error) { sql := `SELECT "y" FROM dir_edge WHERE keyA=? AND kindA=? AND keyB=? AND kindB=?` data, err := dir.query(sql, &keyA, &kindA, &keyB, &kindB) if err != nil { return false, err } if len(data) > 0 { return true, nil } return false, nil } func (dir *directory) PutEdge(keyA, kindA, keyB, kindB string) error { sql := `INSERT INTO dir_edge (keyA, kindA, keyB, kindB) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE ts=NOW()` return dir.exec(sql, keyA, kindA, keyB, kindB) } func (dir *directory) DelEdge(keyA, kindA, keyB, kindB string) error { sql := `DELETE FROM dir_edge WHERE keyA=? AND kindA=? AND keyB=? AND kindB=?` return dir.exec(sql, keyA, kindA, keyB, kindB) } func (dir *directory) GetNeighbors(key, kind string) ([][2]string, error) { out := make([][2]string, 0) sql := `SELECT keyA, kindA, keyB, kindB FROM dir_edge WHERE (keyA=? AND kindA=?) OR (keyB=? AND kindB=?)` edges, err := dir.query(sql, &key, &kind, &key, &kind) if err != nil { return out, err } for _, e := range edges { if e[0] == key && e[1] == kind { out = append(out, [2]string{e[2], e[3]}) } else { out = append(out, [2]string{e[0], e[1]}) } } return out, nil } func (dir *directory) GetEdges() ([]DirEdge, error) { sql := `SELECT keyA, kindA, keyB, kindB, UNIX_TIMESTAMP(ts) FROM dir_edge WHERE keyA != '' AND kindA != '' AND keyB != '' AND kindB != ''` db := SqlDB() out := make([]DirEdge, 0) rows, err := db.Query(sql) if err != nil { return out, errors.Annotatef(err, "SQL: %q", sql) } defer rows.Close() for rows.Next() { row := DirEdge{} err := rows.Scan(&row.KeyA, &row.KindA, &row.KeyB, &row.KindB, &row.Ts) if err != nil { return out, errors.Annotate(err, "rows.Scan()") } out = append(out, row) } return out, nil } func (dir *directory) GetNodes() ([]DirNode, error) { sql := `SELECT pkey, kind, UNIX_TIMESTAMP(ts) FROM dir_node WHERE pkey != '' AND kind != ''` db := SqlDB() out := make([]DirNode, 0) rows, err := db.Query(sql) if err != nil { return out, errors.Annotatef(err, "SQL: %q", sql) } defer rows.Close() for rows.Next() { row := DirNode{} err := rows.Scan(&row.Key, &row.Kind, &row.Ts) if err != nil { return out, errors.Annotate(err, "rows.Scan()") } out = append(out, row) } return out, nil } func (dir *directory) GetNodeAttrs() ([]DirNodeAttr, error) { sql := `SELECT pkey, kind, attr, value, UNIX_TIMESTAMP(ts) FROM dir_node_attr WHERE pkey != '' AND kind != '' AND attr != ''` db := SqlDB() out := make([]DirNodeAttr, 0) rows, err := db.Query(sql) if err != nil { return out, errors.Annotatef(err, "SQL: %q", sql) } defer rows.Close() for rows.Next() { row := DirNodeAttr{} err := rows.Scan(&row.Key, &row.Kind, &row.Attr, &row.Value, &row.Ts) if err != nil { return out, errors.Annotate(err, "rows.Scan()") } out = append(out, row) } return out, nil } /* test data INSERT INTO dir_node (kind, pkey) VALUES ("AD", "angua@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("AD", "carrot@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("AD", "aching@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("AD", "granny@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("AD", "vetinari@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("AD", "vimes@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("AD", "nobbs@dwmail.com"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Angua"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Carrot"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Tiffany"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Mistress Weatherwax"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Patrician"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Sam"); INSERT INTO dir_node (kind, pkey) VALUES ("slack", "Nobby"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "angua@dwmail.com", "slack", "Angua"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "carrot@dwmail.com", "slack", "Carrot"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "aching@dwmail.com", "slack", "Tiffany"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "granny@dwmail.com", "slack", "Mistress Weatherwax"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "vetinari@dwmail.com", "slack", "Patrician"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "vimes@dwmail.com", "slack", "Sam"); INSERT INTO dir_edge (kindA, keyA, kindB, keyB) VALUES ("AD", "nobbs@dwmail.com", "slack", "Nobby"); INSERT INTO dir_node_attr (kind, pkey, attr, value) VALUES ("AD", "angua@dwmail.com", "email", "angua@dwmail.com"); INSERT INTO dir_node_attr (kind, pkey, attr, value) VALUES ("AD", "angua@dwmail.com", "sms", "5551234567" INSERT INTO dir_node_attr (kind, pkey, attr, value) VALUES ("AD", "carrot@dwmail.com", "sms", "5555555555"); */ ================================================ FILE: hal/event.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "regexp" "strings" "time" ) // Evt is a generic container for events processed by the bot. // Event sources are responsible for copying the appropriate data into // the Evt fields. Routing and most plugins will not work if the body // isn't copied, at a minimum. // When ToUser and ToRoom are both true, the event will be delivered twice. // The original event should usually be attached to the Original type Evt struct { ID string `json:"id"` // ID for the event (assigned by upstream or broker) Body string `json:"body"` // body of the event, regardless of source Command string `json:"command"` // optional command associated with the body, typically empty Subject string `json:"subject"` // the subject of the message, if available, typically empty Room string `json:"room"` // the room where the event originated RoomId string `json:"room_id"` // the room id from the source broker User string `json:"user"` // the username that created the event UserId string `json:"user_id"` // the user id from the source broker Time time.Time `json:"time"` // timestamp of the event Broker Broker `json:"broker"` // the broker the event came from IsChat bool `json:"is_chat"` // lets the broker differentiate chats and other events IsBot bool `json:"is_bot"` // message was generated by the bot ToUser bool `json:"to_user"` // when true, always deliver outgoing event via DM ToRoom bool `json:"to_room"` // when true, always deliver outgoing event to the room ToFunc bool `json:"to_func"` // when true, call the ReplyFunc instead of the usual reply path ReplyFunc func(string) // a function to be called with a reply rather than the usual process Original interface{} // the original message container (e.g. slack.MessageEvent) instance *Instance // used by the broker to provide plugin instance metadata isReply bool // the message is a reply isDM bool // the message is/should be a DM isTable bool // the message is a table that should be rendered oob interface{} // oob data that needs to flow between stages (e.g. tables) } // Clone() returns a copy of the event with the same broker/room/user // and a current timestamp. Body, Command, and Subject will be empty. // Time is updated to the current time. // Original is carried through, so nil that if you don't want it preserved. func (e *Evt) Clone() Evt { out := Evt{ ID: e.ID, Room: e.Room, RoomId: e.RoomId, User: e.User, UserId: e.UserId, Time: time.Now(), Broker: e.Broker, IsChat: e.IsChat, IsBot: e.IsBot, Original: e.Original, isReply: e.isReply, isDM: e.isDM, isTable: e.isTable, // do not preserve oob } return out } // Reply is a helper that crafts a new event from the provided string // and initiates the reply on the broker attached to the event. // The message is routed according to preferences and the ToUser/ToRoom // fields on the event. If no preferences are set for the user/room/plugin // the response will go to the room where the command originated. // The "reply-via-dm" preference can be set to "true" to default to // having replies to to DM instead of the room. func (e *Evt) Reply(msg string) { var delivered bool if e.ToFunc { e.ReplyFunc(msg) delivered = true } if e.ToRoom { e.ReplyToRoom(msg) delivered = true } if e.ToUser { e.ReplyDM(msg) delivered = true } if delivered { return } replyVia := e.AsPref().FindKey("reply-via-dm").One() // One() sets Success to false for no results. if replyVia.Success { if replyVia.Value == "true" { e.ReplyDM(msg) return } } // replyVia might be false (or invalid) in which case it falls through to here e.ReplyToRoom(msg) } // Replyf is the same as Reply but allows for string formatting using // fmt.Sprintf() func (e *Evt) Replyf(msg string, a ...interface{}) { e.Reply(fmt.Sprintf(msg, a...)) } // Error replies to the event with the provided error. // Future: need to figure out if there's going to be a kind of error // handling module in Hal for making errors visible in a logging room, // possibly on a different broker... func (e *Evt) Error(err error) { e.Reply(fmt.Sprintf("%s", err)) } // ReplyTable sends a table of data back, formatting it according to // preferences. // TODO: move code from brokers/slack/broker.go/SendTable here // TODO: document preferences here func (e *Evt) ReplyTable(hdr []string, rows [][]string) { out := e.Clone() // may not be necessary if e.Broker != nil { e.Broker.SendTable(out, hdr, rows) } else { panic("hal.Evt.ReplyTable called with nil Broker!") } } // ReplyDM makes it convenient to reply to a user via DM. The user is drawn // from the event's UserId field and passed to the broker's SendDM() method. func (e *Evt) ReplyDM(msg string) { out := e.Clone() out.Body = msg e.Broker.SendDM(out) } // ReplyToRoom crafts a new event from the provided string // and sends it to the room the event originated from. func (e *Evt) ReplyToRoom(msg string) { out := e.Clone() out.Body = msg if e.Broker != nil { e.Broker.Send(out) } else { panic("hal.Evt.Reply called with nil Broker!") } } // BrokerName returns the text name of current broker. func (e *Evt) BrokerName() string { return e.Broker.Name() } // FindPrefs fetches the union of all matching settings from the database // for user, broker, room, and plugin. // Plugins can use the Prefs methods to filter from there. func (e *Evt) FindPrefs() Prefs { broker := e.BrokerName() plugin := e.instance.Plugin.Name return FindPrefs(e.User, broker, e.RoomId, plugin, "") } // InstanceSettings gets all the settings matching the settings defined // by the plugin's Settings field. func (e *Evt) InstanceSettings() Prefs { broker := e.BrokerName() plugin := e.instance.Plugin.Name out := make(Prefs, 0) for _, stg := range e.instance.Plugin.Settings { // ignore room-specific settings for other rooms if stg.Room != "" && stg.Room != e.RoomId { continue } pref := GetPref("", broker, e.RoomId, plugin, stg.Key, stg.Default) out = append(out, pref) } return out } // AsPref returns a a pref with user, room, broker, and plugin set using data // from the event handle. func (e *Evt) AsPref() Pref { // AsPref can be called without an instance for errors, make sure // instance is set before accessing fields var plugin string if e.instance != nil { plugin = e.instance.Plugin.Name } p := Pref{ User: e.UserId, Room: e.RoomId, Broker: e.BrokerName(), Plugin: plugin, } return p } // BodyAsArgv does minimal parsing of the event body, returning an argv-like // array of strings with quoted strings intact (but with quotes removed). // The goal is shell-like, and is not a full implementation. // Leading/trailing whitespace is removed. // Escaping quotes, etc. is not supported. func (e *Evt) BodyAsArgv() []string { // use a simple RE rather than pulling in a package to do this re := regexp.MustCompile(`'[^']*'|"[^"]*"|\S+`) body := strings.TrimSpace(e.Body) argv := re.FindAllString(body, -1) // remove the outer quotes from quoted strings for i, val := range argv { if strings.HasPrefix(val, `'`) && strings.HasSuffix(val, `'`) { tmp := strings.TrimPrefix(val, `'`) argv[i] = strings.TrimSuffix(tmp, `'`) } else if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) { tmp := strings.TrimPrefix(val, `"`) argv[i] = strings.TrimSuffix(tmp, `"`) } } return argv } // ForceToRoom clones the event and returns a copy with ToRoom set to true. // Takes priority over reply-via-dm routing. // Useful for chaining, e.g. evt.ToRoom().Replyf("go away!"). func (e *Evt) ForceToRoom() Evt { out := e.Clone() out.ToRoom = true return out } // ForceToUser clones the event and returns a copy with ToUser set to true. // Takes priority over reply-via-dm routing. // Useful for chaining. func (e *Evt) ForceToUser() Evt { out := e.Clone() out.ToUser = true return out } func (e *Evt) String() string { return fmt.Sprintf("User: %q Room: %q Time: %q Body: %q", e.User, e.Room, e.Time.String(), e.Body) } ================================================ FILE: hal/event_test.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "testing" ) func TestEvtBodyAsArgv(t *testing.T) { evt := Evt{} evt.Body = "a simple flat test" argv := evt.BodyAsArgv() if len(argv) != 4 { fmt.Printf("expected 4 elements, got %d", len(argv)) t.Fail() } // 1 2 3 4 5 6 7 8 evt.Body = ` !foo --this -one "is a little" more (complicated) 'becuase of the quotes' OK` argv = evt.BodyAsArgv() if len(argv) != 8 { fmt.Printf("expected 8 elements, got %d", len(argv)) t.Fail() } // leading/trailing whitespace should be stripped and embedded quotes // should be intact. Escaped quotes are not supported. evt.Body = ` !bar "perhaps 'this challenge' will" '@%$*#@(**W(IOWIE-'------ break TEH BANK "" '' ` argv = evt.BodyAsArgv() if len(argv) != 9 { fmt.Printf("expected 9 elements, got %d", len(argv)) t.Fail() } } ================================================ FILE: hal/kv.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( dbsql "database/sql" "sync" "time" ) const KVTable = ` CREATE TABLE IF NOT EXISTS kv ( pkey VARCHAR(191) NOT NULL, value MEDIUMTEXT, expires DATETIME, ttl INT NOT NULL DEFAULT 0, -- ttl 0 is forever ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(pkey) )` var kvLateInitOnce sync.Once func kvLazyInit() { kvLateInitOnce.Do(func() { SqlInit(KVTable) go kvCleanup() }) } func kvCleanup() { c := time.Tick(time.Minute) for _ = range c { db := SqlDB() _, err := db.Exec("DELETE FROM kv WHERE expires < NOW()") if err != nil { log.Printf("DELETE of expired keys from the DB failed: %s", err) } } } // ExistsKV checks to see if a key exists in the kv. False if any errors are // encountered. func ExistsKV(key string) bool { kvLazyInit() db := SqlDB() var count int64 sql := "SELECT COUNT(pkey) FROM kv WHERE pkey=? AND expires > NOW()" err := db.QueryRow(sql, key).Scan(&count) if err != nil { log.Printf("ExistsKV query %q failed: %s", sql, err) return false } return count > 0 } // GetKV retreives a value from the database. Returns value,ok style. Returns // "", false if the query fails and "", true if there was no value available. func GetKV(key string) (string, bool) { kvLazyInit() db := SqlDB() var value string sql := "SELECT value FROM kv WHERE pkey=? AND expires > NOW()" err := db.QueryRow(sql, key).Scan(&value) if err == dbsql.ErrNoRows { return "", true } else if err != nil { log.Printf("GetKV query %q failed: %s", sql, err) return "", false } return value, true } // SetKV inserts a new value in the database with the provided TTL. If the TTL // is 0, it defaults to 10 years. func SetKV(key, value string, ttl time.Duration) (err error) { kvLazyInit() db := SqlDB() now := time.Now() if ttl == 0 { ttl = time.Hour * 24 * 3650 } sql := "INSERT INTO kv (pkey,value,expires,ttl) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE value=?, expires=?, ttl=?" expires := now.Add(ttl) ttlsecs := int(ttl.Seconds()) _, err = db.Exec(sql, key, value, expires, ttlsecs, value, expires, ttlsecs) if err != nil { log.Printf("SetKV query %q failed: %s", sql, err) } return err } ================================================ FILE: hal/logger.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "os" "sync" "time" ) // Logger provides a handle for using Hal's logging facility. Any Logger created // ultimately uses the same singleton. type Logger struct { prefix string // logging prefix string - eventually will be prepended to all messages } type LogEntry struct { Time time.Time Prefix string Body string IsDebug bool } // logger contains the state for the logger type logger struct { debug bool // for enabling/disabling debug logs logSinks []chan LogEntry // a list of channels to receive log messages dbgSinks []chan LogEntry // a list of channels to receive debug messages logFwdQuit chan struct{} // used to quit the default log message forwarder dbgFwdQuit chan struct{} // used to quit the default debug message forwarder logFwdClose chan struct{} // used to signal it's ok to close the log channel dbgFwdClose chan struct{} // used to signal it's ok to close the debug channel listLock sync.Mutex // protect concurrent access to the sink lists once sync.Once // initialize on first use } // makes log available inside Hal var log Logger // the singleton logger state var gl logger // String returns the LogEntry as a formatted log string. func (l *LogEntry) String() string { var prefix string if l.Prefix != "" { prefix = "[" + l.Prefix + "] " } return l.Time.Format(time.RFC3339) + " " + prefix + l.Body } // initialize allocates channels and starts the background goroutines // that forward output to stdout func (l *logger) initialize() { l.once.Do(func() { l.listLock.Lock() defer l.listLock.Unlock() l.debug = true l.logSinks = make([]chan LogEntry, 1) l.dbgSinks = make([]chan LogEntry, 1) l.logSinks[0] = make(chan LogEntry, 10) l.dbgSinks[0] = make(chan LogEntry, 10) l.logFwdClose = make(chan struct{}) l.dbgFwdClose = make(chan struct{}) // always print logs & debug to stdout by default go l.fwdStdout(l.logSinks[0], l.logFwdClose) go l.fwdStdout(l.dbgSinks[0], l.dbgFwdClose) }) } // fwdStdout is run as a goroutine to read off a channel and print to stdout. func (l *logger) fwdStdout(src chan LogEntry, closed chan struct{}) { for out := range src { print(out.String() + "\n") } closed <- struct{}{} } // SetPrefix sets a new prefix that will be prepended to every message from the logger handle. func (l *Logger) SetPrefix(prefix string) { l.prefix = prefix } // Printf formats the message and propagates it as a log message. func (l *Logger) Printf(msg string, a ...interface{}) { gl.initialize() out := LogEntry{ Time: time.Now(), Prefix: l.prefix, Body: fmt.Sprintf(msg, a...), } for _, sink := range gl.logSinks { sink <- out } } // Println merges the arguments and propagates the result as a log message. func (l *Logger) Println(a ...interface{}) { gl.initialize() out := LogEntry{ Time: time.Now(), Prefix: l.prefix, Body: fmt.Sprintln(a...), } for _, sink := range gl.logSinks { sink <- out } } // Debugf formats the message and propagates it. No work is performed if debugging // is disabled. func (l *Logger) Debugf(msg string, a ...interface{}) { gl.initialize() if gl.debug { out := LogEntry{ Time: time.Now(), Prefix: l.prefix, Body: fmt.Sprintf(msg, a...), IsDebug: true, } for _, sink := range gl.dbgSinks { sink <- out } } } // Fatalf formats the message, propagates the log, then exits the program. func (l *Logger) Fatalf(msg string, a ...interface{}) { gl.initialize() out := LogEntry{ Time: time.Now(), Prefix: l.prefix, Body: fmt.Sprintf(msg, a...), } for i, sink := range gl.logSinks { sink <- out if i > 0 { close(sink) } } l.DisableLogStdout() l.DisableDbgStdout() os.Exit(1) } // Panic panics immediately. No attempt is made to forward/propagate. func (l *Logger) Panic(msg string) { out := LogEntry{ Time: time.Now(), Prefix: l.prefix, Body: msg, } panic(out.String()) } // Panicf formats a message and panics. Not propagated. func (l *Logger) Panicf(msg string, a ...interface{}) { out := LogEntry{ Time: time.Now(), Prefix: l.prefix, Body: fmt.Sprintf(msg, a...), } panic(out.String()) } // IsDebug returns true of debug messages are enabled. func IsDebug() bool { return gl.debug } // IsDebug returns true of debug messages are enabled. func (l *Logger) IsDebug() bool { return gl.debug } // EnableDebug enables debug message propagation. func (l *Logger) EnableDebug() { gl.debug = true } // DisableDebug disables debug message propagation. func (l *Logger) DisableDebug() { gl.debug = false } // NewLogSink creates a new channel that will receive log messages. // It is allocated and ready to go on return. Do not close it. func (l *Logger) NewLogSink() chan LogEntry { gl.initialize() gl.listLock.Lock() defer gl.listLock.Unlock() sink := make(chan LogEntry, 1000) gl.logSinks = append(gl.logSinks, sink) return sink } // NewLogSink creates a new channel that will receive debug messages. // It is allocated and ready to go on return. Do not close it. func (l *Logger) NewDebugSink() chan LogEntry { gl.initialize() gl.listLock.Lock() defer gl.listLock.Unlock() sink := make(chan LogEntry, 1000) gl.dbgSinks = append(gl.dbgSinks, sink) return sink } // DisableLogStdout disables the automatic forwarding of log messages to stdout. func (l *Logger) DisableLogStdout() { gl.initialize() gl.listLock.Lock() defer gl.listLock.Unlock() close(gl.logSinks[0]) <-gl.logFwdClose close(gl.logFwdClose) gl.logSinks = gl.logSinks[1:] } // DisableDbgStdout disables the automatic forwarding of debug messages to stdout. func (l *Logger) DisableDbgStdout() { gl.initialize() gl.listLock.Lock() defer gl.listLock.Unlock() close(gl.dbgSinks[0]) <-gl.dbgFwdClose close(gl.dbgFwdClose) gl.dbgSinks = gl.dbgSinks[1:] } ================================================ FILE: hal/logger_test.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "testing" ) func TestLogger(t *testing.T) { l := Logger{} l.Printf("you SHOULD see this log message on stdout 1/2") l.Debugf("you SHOULD see this debug message on stdout 2/2") l.DisableDebug() l.Debugf("you should NOT see this debug message on stdout 1/1") // these would most likely panic if something was wrong l.DisableDbgStdout() l.DisableLogStdout() // should print nothing, manually verifiable l.Printf("you should NOT see this log message on stdout 1/2") l.Debugf("you should NOT see this debug message on stdout 2/2") } ================================================ FILE: hal/periodic.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "math/rand" "sync" "time" ) type PeriodicFunc struct { Name string Interval time.Duration Function func() NoRand bool // set to true to disable randomizing the first execution last time.Time status string running bool tick <-chan time.Time run chan time.Time exit chan struct{} starting sync.WaitGroup stopping sync.WaitGroup init sync.Once mut sync.Mutex } var periodicData struct { funcs []*PeriodicFunc mut sync.Mutex } func init() { periodicData.funcs = make([]*PeriodicFunc, 0) } // GetPeriodicFunc finds a periodic function by name and returns a pointer to it. // If the name is not found, nil is returned. func GetPeriodicFunc(name string) *PeriodicFunc { periodicData.mut.Lock() defer periodicData.mut.Unlock() for _, pf := range periodicData.funcs { if pf.Name == name { return pf } } return nil } // initialize internal fields, called automatically using pf.init.Do func (pf *PeriodicFunc) initialize() { periodicData.mut.Lock() defer periodicData.mut.Unlock() pf.status = "initialized" pf.exit = make(chan struct{}) pf.tick = make(<-chan time.Time) pf.run = make(chan time.Time) } // loop is the goroutine's program loop func (pf *PeriodicFunc) loop() { pf.mut.Lock() pf.status = "running" pf.running = true pf.mut.Unlock() pf.starting.Done() // TODO: this should capture/handle panics like router.go does pfLoop: for { select { case <-pf.exit: pf.status = "stopped" break pfLoop case t := <-pf.tick: log.Debugf("PeriodicFunc tick %q @ %s", pf.Name, t) pf.runFunc(t) case t := <-pf.run: log.Debugf("PeriodicFunc run %q @ %s", pf.Name, t) pf.runFunc(t) } } pf.mut.Lock() pf.running = false pf.mut.Unlock() pf.stopping.Done() } // runFunc runs the provided function while holding the pf's mutex. func (pf *PeriodicFunc) runFunc(t time.Time) { pf.mut.Lock() defer pf.mut.Unlock() pf.last = t pf.Function() } // Register puts a pf in the global list and makes it available to GetPeriodicFunc. // Anonymous pf's work fine but are not retreivable. func (pf *PeriodicFunc) Register() { found := GetPeriodicFunc(pf.Name) if found != nil { log.Debugf("Found duplicate name %q in list of PeriodicFuncs while registering.", pf.Name) return } periodicData.mut.Lock() defer periodicData.mut.Unlock() periodicData.funcs = append(periodicData.funcs, pf) } // Start a periodic function. func (pf *PeriodicFunc) Start() { pf.init.Do(pf.initialize) pf.mut.Lock() pf.tick = time.Tick(pf.Interval) pf.starting.Add(1) go func() { // avoid a thundering herd by sleeping for a random number of seconds if !pf.NoRand { pf.randSleep() } pf.loop() // may block on pf.mut until Unlock() }() pf.mut.Unlock() pf.starting.Wait() // wait for the goroutine to call Done() // run the first pass immediately pf.run <- time.Now() } // Stop a periodic function. func (pf *PeriodicFunc) Stop() { pf.init.Do(pf.initialize) pf.mut.Lock() defer pf.mut.Unlock() pf.exit <- struct{}{} } // Bump schedules a periodic function to update outside of the scheduled times. // The value of pf.Last() is updated when this is used. func (pf *PeriodicFunc) Bump() { pf.init.Do(pf.initialize) pf.mut.Lock() defer pf.mut.Unlock() pf.run <- time.Now() } // Status returns initialized/running/stopped state as a string. func (pf *PeriodicFunc) Status() string { pf.init.Do(pf.initialize) pf.mut.Lock() defer pf.mut.Unlock() return pf.status } // Last returns the wallclock time of the last run of the function. func (pf *PeriodicFunc) Last() time.Time { pf.init.Do(pf.initialize) pf.mut.Lock() defer pf.mut.Unlock() return pf.last } // randSleep selects a random number between 0 and 60 and sleeps that many // seconds before returning. While sleeping, the pf status is set to "sleeping". func (pf *PeriodicFunc) randSleep() { pf.mut.Lock() pf.status = "sleeping" pf.mut.Unlock() randSecs := rand.Intn(60) time.Sleep(time.Second * time.Duration(randSecs)) } ================================================ FILE: hal/persist_plugins.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const PLUGIN_INST_TABLE = ` CREATE TABLE IF NOT EXISTS plugin_instances ( plugin varchar(191) NOT NULL, broker varchar(191) NOT NULL, room varchar(191) NOT NULL, regex varchar(191) NOT NULL DEFAULT "", ts TIMESTAMP, PRIMARY KEY(plugin, broker, room) ) ` // LoadInstances loads the previously saved plugin instance configuration // from the database and *merges* it with the plugin registry. This should be // idempotent if run multiple times. // TODO: decide if it makes sense to persist settings or just pull the prefs // each time. func (pr *pluginRegistry) LoadInstances() error { log.Printf("Loading plugin instances from the database.") db := SqlDB() err := SqlInit(PLUGIN_INST_TABLE) if err != nil { log.Printf("Failed to initialize the plugin_instances table: %s", err) return err } rows, err := db.Query(`SELECT plugin, broker, room, regex FROM plugin_instances`) if err != nil { log.Printf("LoadInstances SQL query failed: %s", err) return err } defer rows.Close() var pname, bname, roomId, re string for rows.Next() { err := rows.Scan(&pname, &bname, &roomId, &re) if err != nil { log.Printf("LoadInstances rows.Scan() failed: %s", err) return err } // check to see if there is already a runtime instance, create it // if it doesn't exist found := pr.FindInstances(pname, bname, roomId) if len(found) == 0 { // instance is in the DB but not registered, do it now plugin, err := pr.GetPlugin(pname) if err != nil { log.Printf("%q is configured in the database but is not registered. Ignoring.", pname) continue } broker := Router().GetBroker(bname) if broker == nil { log.Fatalf("Broker %q does not exist.", bname) } inst := plugin.Instance(roomId, broker) inst.Regex = re // RE can be overridden per instance // go over the settings and pull preferences before firing up the instance inst.LoadSettingsFromPrefs() err = inst.Register() if err != nil { log.Printf("Could not register plugin instance for plugin %q and room id %q: %s", pname, roomId, err) return err } } else if len(found) == 1 { // already there, move on continue } else { log.Fatalf("BUG: more than 1 plugin instance matched for plugin %q and room id %q", pname, roomId) } } log.Println("Done loading plugin instances.") return nil } // SaveInstances saves plugin instances configurations to the database. func (pr *pluginRegistry) SaveInstances() error { log.Printf("Saving plugin instances to the database.") defer func() { log.Printf("Done saving plugin instances.") }() err := SqlInit(PLUGIN_INST_TABLE) if err != nil { log.Printf("Failed to initialize the plugin_instances table: %s", err) return err } instances := pr.InstanceList() // use a transaction to (relatively) safely wipe & rewrite the whole table db := SqlDB() tx, err := db.Begin() stmt, err := tx.Prepare(`INSERT INTO plugin_instances (plugin, broker, room, regex) VALUES (?, ?, ?, ?)`) // clear the table before writing new records _, err = tx.Exec("TRUNCATE TABLE plugin_instances") for _, inst := range instances { _, err = stmt.Exec(inst.Plugin.Name, inst.Broker.Name(), inst.RoomId, inst.Regex) if err != nil { log.Printf("insert failed: %s", err) return err } } err = tx.Commit() if err != nil { log.Printf("SaveInstances transaction failed: %s", err) return err } return nil } ================================================ FILE: hal/plugins.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "errors" "fmt" "regexp" "sync" ) // pluginRegistry contains the plugin registration data as a singleton type pluginRegistry struct { plugins []*Plugin // registered plugins instances []*Instance // instances of plugins mut sync.Mutex // concurrent access init sync.Once // initialize the singleton once } // Plugin is a function with metadata to assist with message routing. // Plugins are registered at startup by the main program and wired up // to receive events when an instance is created e.g. by the pluginmgr // plugin. // The Command and Regex can be set to pre-filter messages. Regex should // only be used for custom REs. Most commands should use Command to set // a static string and the RE will be generated automatically and consistently. type Plugin struct { Name string // a unique name (used to launch instances) Func func(Evt) // the code to execute for each matched event Init func(*Instance) // plugin hook called at instance creation time Command string // the name of the command for invocation, e.g. "atlas", "uptime" Regex string // the default regex match, to be used when Command isn't sufficient BotEvents bool // set to true to receive events generated by the bot user Settings Prefs // required+autoloaded preferences + defaults Secrets []string // required+autoloaded secret key names } // Instance is an instance of a plugin tied to a room. type Instance struct { *Plugin RoomId string // room id Broker Broker // the broker that produces events Regex string // a regex for filtering messages Settings Prefs // runtime settings for the instance regex *regexp.Regexp // the compiled regex } var pluginRegSingleton pluginRegistry func PluginRegistry() *pluginRegistry { pluginRegSingleton.init.Do(func() { pluginRegSingleton.plugins = make([]*Plugin, 0) pluginRegSingleton.instances = make([]*Instance, 0) }) return &pluginRegSingleton } // Register registers a plugin with the bot. func (p *Plugin) Register() error { pr := PluginRegistry() pr.mut.Lock() defer pr.mut.Unlock() for _, plugin := range pr.plugins { if plugin.Name == p.Name { log.Printf("Ignoring multiple calls to Register() for plugin '%s'", p.Name) return nil } } pr.plugins = append(pr.plugins, p) return nil } // Unregister removes a plugin from the bot. func (p *Plugin) Unregister() error { pr := PluginRegistry() pr.mut.Lock() defer pr.mut.Unlock() plugins := make([]*Plugin, len(pr.plugins)-1) var i int for _, plugin := range pr.plugins { if plugin.Name == p.Name { continue } else { // TODO: this might segfault if this is called on an unregistered or never-registered plugin plugins[i] = plugin i++ } } pr.plugins = plugins return nil } // Instance creates an instance of a plugin. It is *not* registered (and // therefore not considered by the router until that is done). func (p *Plugin) Instance(roomId string, broker Broker) *Instance { i := Instance{ Plugin: p, RoomId: roomId, Broker: broker, Regex: p.Regex, } return &i } // Register an instance with the bot so that it starts receiving messages. func (inst *Instance) Register() error { pr := PluginRegistry() pr.mut.Lock() defer pr.mut.Unlock() // default to the plugin's default if no RE was provided if inst.Regex == "" { inst.Regex = inst.Plugin.Regex } // TODO: the default regex still doesn't always show up // TODO: manually check/return the error so the bot doesn't crash inst.regex = regexp.MustCompile(inst.Regex) // call the instance init handler if inst.Plugin.Init != nil { inst.Plugin.Init(inst) } // once an instance is registered, the router will automatically // pick it up on the next message it processes pr.instances = append(pr.instances, inst) log.Debugf("Registered plugin %q in room id %q on broker %q with RE match %q", inst.Name, inst.RoomId, inst.Broker.Name(), inst.regex) return nil } // Unregister removes an instance from the list of plugin instances. func (inst *Instance) Unregister() error { pr := PluginRegistry() pr.mut.Lock() defer pr.mut.Unlock() var idx int for j, i := range pr.instances { // TODO: verify if pointer equality is sufficient if i == inst { idx = j break } } // delete the instance from the list pr.instances = append(pr.instances[:idx], pr.instances[idx+1:]...) log.Printf("Unregistered plugin '%s' from room id '%s'", inst.Name, inst.RoomId) return nil } // LoadSettingsFromPrefs loads all of the settings specified in the plugin // Settings list into the instance's Settings list. Any current settings // are replaced. The search is run with room and plugin set to whatever // values the instance has. func (inst *Instance) LoadSettingsFromPrefs() { pr := PluginRegistry() pr.mut.Lock() defer pr.mut.Unlock() ips := inst.Plugin.Settings.Clone() // wipe the previous settings inst.Settings = make(Prefs, len(ips)) for i, ppref := range ips { ppref.Room = inst.RoomId ppref.Broker = inst.Broker.Name() ppref.Plugin = inst.Plugin.Name ipref := ppref.Get() inst.Settings[i] = ipref } } // SaveSettingsToPrefs saves runtime instance preferences to the prefs // table in the database. func (inst *Instance) SaveSettingsToPrefs() { pr := PluginRegistry() pr.mut.Lock() defer pr.mut.Unlock() for _, ipref := range inst.Settings { ipref.Set() } } // PluginList returns a snapshot of the plugin list at call time. func (pr *pluginRegistry) PluginList() []*Plugin { pr.mut.Lock() defer pr.mut.Unlock() out := make([]*Plugin, len(pr.plugins)) copy(out, pr.plugins) // intentional shallow copy return out } // InstanceList returns a snapshot of the instance list at call time. func (pr *pluginRegistry) InstanceList() []*Instance { pr.mut.Lock() defer pr.mut.Unlock() // this gets called in the router for every message that comes in, so it // might come to pass that this will perform poorly, but for now with a // relatively small number of instances we'll take the copy hit in exchange // for not having to think about concurrent access to the list out := make([]*Instance, len(pr.instances)) copy(out, pr.instances) // intentional shallow copy return out } // GetPlugin returns the plugin specified by its name string. func (pr *pluginRegistry) GetPlugin(name string) (*Plugin, error) { pr.mut.Lock() defer pr.mut.Unlock() for _, p := range pr.plugins { if p.Name == name { return p, nil } } return nil, errors.New(fmt.Sprintf("no such plugin: %q", name)) } // FindInstances returns the plugin instances that match the provided // room id, broker, and plugin name. func (pr *pluginRegistry) FindInstances(roomId, bname, plugin string) []*Instance { pr.mut.Lock() defer pr.mut.Unlock() out := make([]*Instance, 0) for _, i := range pr.instances { if i.Plugin.Name == plugin && i.Broker.Name() == bname && i.RoomId == roomId { out = append(out, i) } } return out } // ActivePluginList returns a list of plugins that have registered instances. func (pr *pluginRegistry) ActivePluginList() []*Plugin { out := make([]*Plugin, 0) // create a unique list of plugins in use by instances and return that for _, i := range pr.InstanceList() { ip := i.Plugin seen := false for _, p := range out { if p.Name == ip.Name { seen = true } } // make sure each plugin is only inserted once if !seen { out = append(out, ip) } } return out } // InactivePluginList returns a list of plugins that do not have any registered instances. func (pr *pluginRegistry) InactivePluginList() []*Plugin { out := make([]*Plugin, 0) inst := pr.InstanceList() // for each plugin, add it to the out list only if there are no instances using it for _, p := range pr.PluginList() { active := false for _, i := range inst { if p.Name == i.Plugin.Name { active = true } } if !active { out = append(out, p) } } return out } func (p *Plugin) String() string { return p.Name } func (inst *Instance) String() string { return fmt.Sprintf("%s/%s", inst.Name, inst.RoomId) } ================================================ FILE: hal/prefs.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bytes" "fmt" "sort" "strings" ) // provides a persistent configuration store // Order of precendence for prefs: // user -> room -> broker -> plugin -> global -> default // PrefsTable contains the SQL to create the prefs table // key field is called pkey because key is a reserved word const PrefsTable = ` CREATE TABLE IF NOT EXISTS prefs ( id INT NOT NULL AUTO_INCREMENT, -- only used for deleting/updating by id user VARCHAR(191) DEFAULT "", room VARCHAR(191) DEFAULT "", broker VARCHAR(191) DEFAULT "", plugin VARCHAR(191) DEFAULT "", pkey VARCHAR(191) NOT NULL, value MEDIUMTEXT, INDEX(id), -- required by mysql for non-PK auto_increment -- InnoDB limits indexes to 767 bytes so have the PK only index the first -- 32 characters of each column as a compromise -- (5 cols * 4 bytes * 32 chars = 640) PRIMARY KEY(user(32), room(32), broker(32), plugin(32), pkey(32)) )` /* -- test data, will remove once there are automated tests INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "", "", "", "foo", "user"); INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "CORE", "", "", "foo", "user-room"); INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "CORE", "slack", "", "foo", "user-room-broker"); INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "CORE", "slack", "uptime", "foo", "user-room-broker-plugin"); INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "", "slack", "uptime", "foo", "user-broker-plugin"); INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "CORE", "", "uptime", "foo", "user-room-plugin"); INSERT INTO prefs (user,room,broker,plugin,pkey,value) VALUES ("tobert", "", "", "uptime", "foo", "user-plugin"); */ // !prefs list --scope plugin --plugin autoresponder // !prefs get --scope room --plugin autoresponder --room CORE --key timezone // !prefs set --scope user --plugin autoresponder --room CORE // Pref is a key/value pair associated with a combination of user, plugin, // borker, or room. type Pref struct { User string `json:"user"` Plugin string `json:"plugin"` Broker string `json:"broker"` Room string `json:"room"` Key string `json:"key"` Value string `json:"value"` Default string `json:"default"` Success bool `json:"-"` Error error `json:"-"` Id int `json:"id"` } type Prefs []Pref // GetPref will retreive the most-specific preference from pref // storage using the parameters provided. This is a bit like pattern // matching. If no match is found, the provided default is returned. // TODO: explain this better func GetPref(user, broker, room, plugin, key, def string) Pref { pref := Pref{ User: user, Room: room, Broker: broker, Plugin: plugin, Key: key, Default: def, } up := pref.Get() if up.Success { return up } // no match, return the default pref.Value = def return pref } // SetPref sets a preference and is shorthand for Pref{}.Set(). func SetPref(user, broker, room, plugin, key, value string) error { pref := Pref{ User: user, Room: room, Broker: broker, Plugin: plugin, Key: key, Value: value, } return pref.Set() } // GetPrefs retrieves a set of preferences from the database. The // settings are matched exactly on user,broker,room,plugin. // e.g. GetPrefs("", "", "", "uptime") would get only records that // have user/broker/room set to the empty string and room // set to "uptime". A record with user "pford" and plugin "uptime" // would not be included. func GetPrefs(user, broker, room, plugin string) Prefs { pref := Pref{ User: user, Broker: broker, Room: room, Plugin: plugin, } return pref.get() } // FindPrefs gets all records that match any of the inputs that are // not empty strings. (hint: user="x", broker="y"; WHERE user=? OR broker=?) func FindPrefs(user, broker, room, plugin, key string) Prefs { pref := Pref{ User: user, Broker: broker, Room: room, Plugin: plugin, Key: key, } return pref.find(false) } // RmPrefId removes a preference from the database by its numeric id. func RmPrefId(id int) error { db := SqlDB() SqlInit(PrefsTable) _, err := db.Exec("DELETE FROM prefs WHERE id=?", &id) return err } // Get retrieves a value from the database. If the database returns // an error, Success will be false and the Error field will be populated. func (in *Pref) Get() Pref { prefs := in.get() if len(prefs) == 1 { return prefs[0] } else if len(prefs) > 1 { panic("TOO MANY PREFS") } else if len(prefs) == 0 { out := *in // only set success to false if there is also an error // queries with 0 rows are successful if out.Error != nil { out.Success = false } else { out.Success = true out.Value = out.Default } return out } panic("BUG: should be impossible to reach this point") } // GetPrefs returns all preferences that match the fields set in the handle. func (in *Pref) GetPrefs() Prefs { return in.get() } func (in *Pref) get() Prefs { db := SqlDB() SqlInit(PrefsTable) sql := `SELECT user,room,broker,plugin,pkey,value,id FROM prefs WHERE user=? AND room=? AND broker=? AND plugin=?` params := []interface{}{&in.User, &in.Room, &in.Broker, &in.Plugin} // only query by key if it's specified, otherwise get all keys for the selection if in.Key != "" { sql += " AND pkey=?" params = append(params, &in.Key) } rows, err := db.Query(sql, params...) if err != nil { log.Printf("Returning default due to SQL query failure: %s", err) return Prefs{} } defer rows.Close() out := make(Prefs, 0) for rows.Next() { p := *in err := rows.Scan(&p.User, &p.Room, &p.Broker, &p.Plugin, &p.Key, &p.Value, &p.Id) if err != nil { log.Printf("Returning default due to row iteration failure: %s", err) p.Success = false p.Value = in.Default p.Error = err } else { p.Success = true p.Error = nil } out = append(out, p) } return out } // Set writes the value and returns a new struct with the new value. func (in *Pref) Set() error { db := SqlDB() err := SqlInit(PrefsTable) if err != nil { log.Printf("Failed to initialize the prefs table: %s", err) return err } sql := `INSERT INTO prefs (value,user,room,broker,plugin,pkey) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE value=?, user=?, room=?, broker=?, plugin=?, pkey=?` params := []interface{}{ &in.Value, &in.User, &in.Room, &in.Broker, &in.Plugin, &in.Key, &in.Value, &in.User, &in.Room, &in.Broker, &in.Plugin, &in.Key, } _, err = db.Exec(sql, params...) if err != nil { log.Printf("Pref.Set() write failed: %s", err) return err } return nil } // Set writes the value and returns a new struct with the new value. func (in *Pref) Delete() error { db := SqlDB() err := SqlInit(PrefsTable) if err != nil { log.Printf("Failed to initialize the prefs table: %s", err) return err } sql := `DELETE FROM prefs WHERE user=? AND room=? AND broker=? AND plugin=? AND pkey=?` // TODO: verify only one row was deleted _, err = db.Exec(sql, &in.User, &in.Room, &in.Broker, &in.Plugin, &in.Key) if err != nil { log.Printf("Pref.Delete() write failed: %s", err) return err } return nil } // Find retrieves all preferences from the database that match any field in the // handle's fields. If the Key field is set, it is matched first. // The resulting list is sorted before it is returned. // Unlike Get(), empty string fields are not included in the (generated) query // so it can potentially match a lot of rows. // Returns an empty list and logs upon errors. func (p Pref) Find() Prefs { return p.find(false) } func (p Pref) FindKey(key string) Prefs { p.Key = key return p.find(true) } // FindKey is like Find() but the provide key is required. func FindKey(key string) Prefs { p := Pref{Key: key} return p.find(true) } func (p Pref) find(keyRequired bool) Prefs { db := SqlDB() SqlInit(PrefsTable) fields := make([]string, 0) params := make([]interface{}, 0) // NOTE: the order of these statements is important! if keyRequired { // ok for it to be "" to match no key, but still required // query is appended below params = append(params, p.Key) } if p.User != "" { fields = append(fields, "user=?") params = append(params, p.User) } if p.Room != "" { fields = append(fields, "room=?") params = append(params, p.Room) } if p.Broker != "" { fields = append(fields, "broker=?") params = append(params, p.Broker) } if p.Plugin != "" { fields = append(fields, "plugin=?") params = append(params, p.Plugin) } if !keyRequired && p.Key != "" { fields = append(fields, "pkey=?") params = append(params, p.Key) } q := bytes.NewBufferString("SELECT user,room,broker,plugin,pkey,value,id\n") q.WriteString("FROM prefs\n") if keyRequired || len(fields) > 0 { q.WriteString("\nWHERE ") } if keyRequired { q.WriteString("pkey=? AND (") } // TODO: maybe it's silly to make it easy for Find() to get all preferences // but let's cross that bridge when we come to it if len(fields) > 0 { // might make sense to add a param to this func to make it easy to // switch this between AND/OR for unions/intersections q.WriteString(strings.Join(fields, "\n OR ")) } if keyRequired { q.WriteString("\n)") } out := make(Prefs, 0) rows, err := db.Query(q.String(), params...) if err != nil { log.Println(q.String()) log.Printf("Query failed: %s", err) return out } defer rows.Close() for rows.Next() { row := Pref{} err = rows.Scan(&row.User, &row.Room, &row.Broker, &row.Plugin, &row.Key, &row.Value, &row.Id) // improbable in practice - follows previously mentioned conventions for errors if err != nil { log.Printf("Fetching a row failed: %s\n", err) row.Error = err row.Success = false row.Value = p.Default } else { row.Error = nil row.Success = true } out = append(out, row) } sort.Sort(out) return out } // Clone returns a full/deep copy of the Prefs list. func (prefs Prefs) Clone() Prefs { out := make(Prefs, len(prefs)) for i, pref := range prefs { copy := pref out[i] = copy } return out } // One returns the most-specific preference from the Prefs according // to the precedence order of user>room>broker>plugin>global. // func (prefs Prefs) One() Pref { if len(prefs) == 0 { return Pref{Success: false} } sort.Sort(prefs) return prefs[0] } // SetKey returns a copy of the pref with the key set to the provided string. // Useful for chaining e.g. fooPrefs := p.SetKey("foo").Find(). func (pref Pref) SetKey(key string) Pref { pref.Key = key // already a copy return pref } // SetUser returns a copy of the pref with the User set to the provided string. func (pref Pref) SetUser(user string) Pref { pref.User = user return pref } // SetBroker returns a copy of the pref with the Broker set to the provided string. func (pref Pref) SetBroker(broker string) Pref { // TODO: validate? pref.Broker = broker return pref } // User filters the preference list by user, returning a new Prefs // e.g. uprefs = prefs.User("adent") func (prefs Prefs) User(user string) Prefs { out := make(Prefs, 0) for _, pref := range prefs { if pref.User == user { out = append(out, pref) } } return out } // Room filters the preference list by room, returning a new Prefs // e.g. instprefs = prefs.Room("magrathea").Plugin("uptime").Broker("slack") func (prefs Prefs) Room(room string) Prefs { out := make(Prefs, 0) for _, pref := range prefs { if pref.Room == room { out = append(out, pref) } } return out } // Broker filters the preference list by broker, returning a new Prefs func (prefs Prefs) Broker(broker string) Prefs { out := make(Prefs, 0) for _, pref := range prefs { if pref.Broker == broker { out = append(out, pref) } } return out } // Plugin filters the preference list by plugin, returning a new Prefs func (prefs Prefs) Plugin(plugin string) Prefs { out := make(Prefs, 0) for _, pref := range prefs { if pref.Plugin == plugin { out = append(out, pref) } } return out } // Key filters the preference list by key, returning a new Prefs func (prefs Prefs) Key(key string) Prefs { out := make(Prefs, 0) for _, pref := range prefs { if pref.Key == key { out = append(out, pref) } } return out } // Value filters the preference list by key, returning a new Prefs func (prefs Prefs) Value(value string) Prefs { out := make(Prefs, 0) for _, pref := range prefs { if pref.Value == value { out = append(out, pref) } } return out } // Table returns Prefs as a 2d list ready to hand off to e.g. hal.AsciiTable() func (prefs Prefs) Table() [][]string { out := make([][]string, 1) out[0] = []string{"User", "Room", "Broker", "Plugin", "Key", "Value", "ID"} for _, pref := range prefs { m := []string{ pref.User, pref.Room, pref.Broker, pref.Plugin, pref.Key, pref.Value, fmt.Sprintf("%d", pref.Id), } out = append(out, m) } return out } func (ps Prefs) Len() int { return len(ps) } func (ps Prefs) Swap(i, j int) { ps[i], ps[j] = ps[j], ps[i] } func (ps Prefs) Less(i, j int) bool { return ps[i].precedence() < ps[j].precedence() } func (p *Pref) precedence() int { if !p.Success { return 0 } if p.User != "" { return 5 } if p.Room != "" { return 4 } if p.Broker != "" { return 3 } if p.Plugin != "" { return 2 } if p.Key != "" { return 1 } return 0 } func (p *Pref) String() string { return fmt.Sprintf(`Pref{ User: %q, Room: %q, Broker: %q, Plugin: %q, Key: %q, Value: %q, Default: %q, Success: %t, Error: %v, Id: %d, }`, p.User, p.Room, p.Broker, p.Plugin, p.Key, p.Value, p.Default, p.Success, p.Error, p.Id) } func (p *Prefs) String() string { data := p.Table() return AsciiTable(data[0], data[1:]) } ================================================ FILE: hal/router.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "runtime/debug" "strings" "sync" ) // RouterCTX holds the router's context, including input/output chans. type RouterCTX struct { brokers map[string]Broker in chan *Evt // messages from brokers --> plugins out chan *Evt // messages from plugins --> brokers quit chan struct{} // to shut down the router loop update chan struct{} // to notify the router that the instance list changed mut sync.Mutex init sync.Once } type fwdBroker struct { from Broker to Broker } var routerSingleton RouterCTX // Router returns the singleton router context. The router is initialized // on the first call to this function. func Router() *RouterCTX { routerSingleton.init.Do(func() { routerSingleton.in = make(chan *Evt, 1000) routerSingleton.out = make(chan *Evt, 1000) routerSingleton.quit = make(chan struct{}, 1) routerSingleton.update = make(chan struct{}, 1) routerSingleton.brokers = make(map[string]Broker) }) return &routerSingleton } // forwardChan forwards events from one chan of to another. // TODO: figure out if this needs to check for closed channels, etc. func forwardChan(from, to chan *Evt) { for { select { case evt := <-from: to <- evt } } } // AddBroker adds a broker to the router and starts forwarding // events between it and the router. func (r *RouterCTX) AddBroker(b Broker) { r.mut.Lock() defer r.mut.Unlock() if _, exists := r.brokers[b.Name()]; exists { panic(fmt.Sprintf("BUG: broker '%s' added > 1 times.", b.Name())) } b2r := make(chan *Evt, 1000) // messages from the broker to the router // start the broker's event stream go b.Stream(b2r) // forward events from the broker to the router's input channel go forwardChan(b2r, r.in) r.brokers[b.Name()] = b } func (r *RouterCTX) Send(evt Evt) { r.in <- &evt } // GetBroker retrieves a broker handle by name. func (r *RouterCTX) GetBroker(name string) Broker { r.mut.Lock() defer r.mut.Unlock() if broker, exists := r.brokers[name]; exists { return broker } return nil } // Brokers returns all brokers that have been added to the router. // The returned list is not in any particular order. func (r *RouterCTX) Brokers() []Broker { r.mut.Lock() defer r.mut.Unlock() out := make([]Broker, len(r.brokers)) i := 0 for _, b := range r.brokers { out[i] = b i++ } return out } func (r *RouterCTX) Quit() { r.quit <- struct{}{} } // Route is the main method for the router. It blocks and should be run in a // goroutine exactly once. Running more than one router in the same process // will result in shenanigans. func (r *RouterCTX) Route() { for { select { case <-r.quit: close(r.quit) break case evt := <-r.in: // events are processed concurrently, plugins are not go r.inEvent(evt) case evt := <-r.out: go r.outEvent(evt) } } } // inEvent processes one event and is intended to run in a goroutine. func (r *RouterCTX) inEvent(evt *Evt) { var pname string // must be in the recovery handler's scope // detect invalid commands & count executions var ranPlugins int // get a snapshot of the instance list // TODO: keep an eye on the cost of copying this list for every message pr := PluginRegistry() instances := pr.InstanceList() // if a plugin panics, catch it & log it // TODO: report errors to a channel? defer func() { if r := recover(); r != nil { log.Printf("recovered panic in plugin %q\n", pname) log.Printf("panic: %q", r) debug.PrintStack() } }() // if the event doesn't have a command, try to extract one if evt.Command == "" { body := strings.TrimSpace(evt.Body) // check for a leading "!", which indicates it's a command if strings.HasPrefix(body, "!") { parts := strings.SplitN(body, " ", 2) // split off the first word if len(parts) > 0 { evt.Command = strings.TrimPrefix(parts[0], "!") } } } for _, inst := range instances { // the recovery handler will pick this up in a panic to provide // the name of the plugin that caused the panic pname = inst.Plugin.Name // check if it's the correct room if evt.RoomId != inst.RoomId { continue } // only process IsBot messages if the plugin has asked for them if evt.IsBot && !inst.Plugin.BotEvents { continue } // plugins with no RE filter receive every event noFilter := (inst.Regex == "" && inst.Plugin.Command == "") // events that match the RE filter are passed onto the plugin matchesRegex := (inst.Regex != "" && inst.regex.MatchString(evt.Body)) // events with a Command field that matches exactly are passed to the plugin isCommand := (inst.Plugin.Command != "" && evt.Command == inst.Plugin.Command) // forward to plugins when any of the above rules passes if noFilter || matchesRegex || isCommand { // this will copy the struct twice. It's intentional to avoid // mutating the evt between calls. The plugin func signature // forces the second copy. evtcpy := *evt // pass the plugin instance pointer to the plugin function so // it can access its fields for settings, etc. evtcpy.instance = inst // call the plugin function // this may block other plugins from processing the same event but // since it's already in a goroutine, other events won't be blocked inst.Func(evtcpy) ranPlugins++ } } if evt.IsBot { return } // no plugins were executed but evt.Body looks like a command if ranPlugins == 0 && strings.HasPrefix(strings.TrimSpace(evt.Body), "!") { // automatically load the pluginmgr plugin if someone tries to use it and it wasn't attached // don't bother if the bot was built without pluginmgr, in which case err != nil if strings.HasPrefix(strings.TrimSpace(evt.Body), "!plugin") { mgr, err := pr.GetPlugin("pluginmgr") if err == nil { inst := mgr.Instance(evt.RoomId, evt.Broker) evtcpy := *evt evtcpy.instance = inst inst.Func(evtcpy) } } else { evt.Replyf("invalid bot command: %q (IsBot: %t) (%d plugins were executed for the event).", evt.Body, evt.IsBot, ranPlugins) } } } func (r *RouterCTX) outEvent(evt *Evt) { // TODO: fill in this stub log.Printf("STUB: did nothing with event %s", evt.String()) } ================================================ FILE: hal/secrets.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "crypto/aes" "crypto/cipher" "crypto/rand" "io" "sync" ) // secrets stores a plaintext key/value store for // sensitive data that the bot and plugins need to operate // along with methods for persisting encrypted copies to the database type SecretStore struct { key []byte // encryption key for persistence keyed bool // track whether the key has been set kv map[string]string // the in-memory k/v store mut sync.Mutex // protect concurrent access init sync.Once // singleton initialization itbl sync.Once // table initialization } var secrets SecretStore // SECRETS_TABLE holds encrypted key/value pairs along with their nonces. // uses VARBINARY instead of BINARY to avoid null termination issues. const SECRETS_TABLE = ` CREATE TABLE IF NOT EXISTS secrets ( pkey VARCHAR(191) NOT NULL, -- plaintext key sval VARBINARY(16384) NOT NULL, -- AES/GCM sealed value nonce VARBINARY(12) NOT NULL, -- GCM nonce for the value ts TIMESTAMP, -- timestamp, for debugging/cleanup PRIMARY KEY(pkey) )` // for temporarily holding encrypted data type ssRec struct { pkey []byte sval []byte nonce []byte } // 256-bit AES key and 96-bit nonce size in bytes const KEY_SIZE = 32 const NONCE_SIZE = 12 // Secrets returns a handle for accessing secrets managed by hal. func Secrets() *SecretStore { secrets.init.Do(func() { secrets.kv = make(map[string]string) secrets.key = make([]byte, KEY_SIZE) secrets.keyed = false }) return &secrets } // SetEncryptionKey sets the key used to encrypt/decrypt credentials // stored in the database. This needs to be called before anything // will work. func (ss *SecretStore) SetEncryptionKey(in []byte) { ss.mut.Lock() defer ss.mut.Unlock() // do not rely on the caller's memory: make a copy done := copy(ss.key, in) // catch unlikely errors and anyone trying to use a smaller key if done != KEY_SIZE { log.Fatalf("BUG: SetEncryptionKey failed to store the key. Only %d bytes copied.", done) } ss.keyed = true } // Get returns the value of a key from the secret store. // If the key doesn't exist, empty string is returned. // To check existence, use Exists(string). func (ss *SecretStore) Get(key string) string { ss.mut.Lock() defer ss.mut.Unlock() if _, exists := ss.kv[key]; exists { return ss.kv[key] } else { return "" } } // Exists checks to see if the provided key exists // in the secret store. func (ss *SecretStore) Exists(key string) bool { ss.mut.Lock() defer ss.mut.Unlock() _, exists := ss.kv[key] return exists } // Put adds a key/value to the in-memory secret store. // Put'ing a key that already exists overwrites the previous // value. The secret store is not persisted at this point, // an additional call to Save() is required. func (ss *SecretStore) Set(key, value string) { ss.mut.Lock() defer ss.mut.Unlock() ss.kv[key] = value } // Put is an alias for Set func (ss *SecretStore) Put(key, value string) { ss.Set(key, value) } // Delete removes the key from the in-memory secret store. // This is not persisted. func (ss *SecretStore) Delete(key string) { ss.mut.Lock() defer ss.mut.Unlock() delete(ss.kv, key) } // Dump returns a copy of the kv store. DO NOT USE IN PLUGINS. // This returns an UNENCRYPTED copy of the kv store for CLI // tools and debugging. This might go away. func (ss *SecretStore) Dump() map[string]string { out := make(map[string]string) ss.mut.Lock() defer ss.mut.Unlock() for k, v := range ss.kv { out[k] = v } return out } // Load secrets from the database and decrypt them into hal's in-memory secret // store. The database-side secrets will be added to the existing store, // overwriting on conflict (e.g. the database secrets). // Any errors during this process are fatal. func (ss *SecretStore) LoadFromDB() { if !ss.keyed { panic("The secret store key has not been set!") } ss.initTable() db := SqlDB() rows, err := db.Query("SELECT pkey, sval, nonce FROM secrets") if err != nil { log.Fatalf("Could not read secrets from the database: %s", err) } defer rows.Close() // encrypted key/value and key/nonce encrypted := make([]ssRec, 0) // pull the encrypted data into memory for rows.Next() { ssr := ssRec{} err := rows.Scan(&ssr.pkey, &ssr.sval, &ssr.nonce) if err != nil { log.Fatalf("Could not rows.Scan: %s", err) } encrypted = append(encrypted, ssr) } gcm := ss.getGCM() // decrypt the keys/values into ss.kv for _, ssr := range encrypted { value, err := gcm.Open(nil, ssr.nonce, ssr.sval, nil) if err != nil { log.Fatalf("value decryption failed: %s\n", err) } ss.kv[string(ssr.pkey)] = string(value) } } // Serialize the secret store, encrypt it, and store it in the database. // Any errors during this process are fatal. func (ss *SecretStore) SaveToDB() { gcm := ss.getGCM() ss.initTable() ss.mut.Lock() defer ss.mut.Unlock() db := SqlDB() tx, err := db.Begin() if err != nil { log.Fatalf("Failed to create transaction for saving secrets: %s", err) } insert, err := tx.Prepare(`INSERT INTO secrets (pkey,sval,nonce) VALUES (?,?,?)`) if err != nil { log.Fatalf("Failed to prepare insert query: %s", err) } defer insert.Close() _, err = tx.Exec(`TRUNCATE TABLE secrets`) if err != nil { log.Fatalf("Failed to truncate secrets table: %s", err) } // use a unique nonce for each key/value pair // TODO: ask infosec if using the nonce for both is OK for key, val := range ss.kv { nonce, err := ss.mkNonce() if err != nil { log.Fatalf("Could not create a new nonce: %s", err) } sealed := gcm.Seal(nil, nonce, []byte(val), nil) _, err = insert.Exec(key, sealed, nonce) if err != nil { log.Fatalf("Could not write encrypted key/value/nonce to DB: %s", err) } } err = tx.Commit() if err != nil { log.Fatalf("secrets.SaveToDB transaction failed: %s", err) } } // initTable runs the table initialization statement once func (ss *SecretStore) initTable() { ss.itbl.Do(func() { err := SqlInit(SECRETS_TABLE) if err != nil { log.Printf("Failed to initialize the secrets table: %s", err) } }) } func (ss *SecretStore) WipeDB() { SqlDB().Exec(`TRUNCATE TABLE secrets`) } func (ss *SecretStore) InitDB() { ss.initTable() ss.SaveToDB() } func (ss *SecretStore) mkNonce() ([]byte, error) { nonce := make([]byte, NONCE_SIZE) _, err := io.ReadFull(rand.Reader, nonce) if err != nil { log.Printf("Could not acquire nonce: %s", err) return nil, err } return nonce, nil } // getGCM returns an AES/GCM cipher configured with the default nonce size. func (ss *SecretStore) getGCM() cipher.AEAD { if !ss.keyed { panic("The secret store key has not been set!") } ss.mut.Lock() defer ss.mut.Unlock() block, err := aes.NewCipher(ss.key) if err != nil { log.Fatalf("aes.NewCipher failed: %s", err) } gcm, err := cipher.NewGCM(block) if err != nil { log.Fatalf("cipher.NewGCM(aes block) failed: %s", err) } return gcm } ================================================ FILE: hal/secrets_test.go ================================================ package hal_test /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "testing" "github.com/netflix/hal-9001/hal" ) func TestSecretsBasic(t *testing.T) { secrets := hal.Secrets() // make sure it returns the empty value if secrets.Get("whatever") != "" { t.Fail() } if secrets.Exists("whatever") { t.Fail() } secrets.Put("whatever", "foo") if !secrets.Exists("whatever") { t.Fail() } if secrets.Get("whatever") != "foo" { t.Fail() } secrets.Delete("whatever") if secrets.Exists("whatever") { t.Fail() } } ================================================ FILE: hal/sqldb.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "database/sql" "strings" "sync" _ "github.com/go-sql-driver/mysql" ) var sqldbSingleton *sql.DB var initSqlDbOnce sync.Once var sqlMapMutex sync.Mutex var sqlInitCache map[string]struct{} const SECRETS_KEY_DSN = "hal.dsn" // DB returns the database singleton. func SqlDB() *sql.DB { initSqlDbOnce.Do(func() { secrets := Secrets() dsn := secrets.Get(SECRETS_KEY_DSN) if dsn == "" { panic("Startup error: SetSqlDB(dsn) must come before any calls to hal.SqlDB()") } var err error sqldbSingleton, err = sql.Open("mysql", strings.TrimSpace(dsn)) if err != nil { log.Fatalf("Could not connect to database: %s\n", err) } // make sure the connection is in full utf-8 mode sqldbSingleton.Exec("SET NAMES utf8mb4") err = sqldbSingleton.Ping() if err != nil { log.Fatalf("Pinging database failed: %s\n", err) } sqlInitCache = make(map[string]struct{}) }) return sqldbSingleton } // ForceSqlDBHandle can be used to forcibly replace the DB handle with another // one, e.g. go-sqlmock. This is mainly here for tests, but it's also useful for // things like examples/repl to operate with no database. func ForceSqlDBHandle(db *sql.DB) { // trigger the sync.Once so the init code doesn't fire initSqlDbOnce.Do(func() {}) sqldbSingleton = db } // SqlInit executes the provided SQL once per runtime. // Execution is not tracked across restarts so statements still need // to use CREATE TABLE IF NOT EXISTS or other methods of achieving // idempotent execution. Errors are returned unmodified, including // primary key violations, so you may ignore them as needed. func SqlInit(sqlTxt string) error { sqlMapMutex.Lock() defer sqlMapMutex.Unlock() db := SqlDB() // avoid a database round-trip by checking an in-memory cache // fall through and hit the DB on cold cache if _, exists := sqlInitCache[sqlTxt]; exists { return nil } // clean up a little sqlTxt = strings.TrimSpace(sqlTxt) sqlTxt = strings.TrimSuffix(sqlTxt, ";") // check if it's a simple create table, add engine/charset if unspecified lowSql := strings.ToLower(sqlTxt) if strings.HasPrefix(lowSql, "create table") && strings.HasSuffix(lowSql, ")") { // looks like no engine or charset was specified, add it // "utf8" has incomplete support. "utf8mb4" provides full utf8 support // https://mathiasbynens.be/notes/mysql-utf8mb4 sqlTxt = sqlTxt + " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" } // execute the statement _, err := db.Exec(sqlTxt) if err != nil { log.Printf("SqlInit() failed on statement '%s':\n%s", sqlTxt, err) return err } sqlInitCache[sqlTxt] = struct{}{} return nil } ================================================ FILE: hal/text2image.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Utilities to render text to an image. Useful for sending things like // AsciiTable and Utf8Table to chat services that aren't consistent about // fixed-width rendering. Might be fun for plugins too... // // A fixed-width font is embedded in this file to avoid depdending on external // font files/paths. // // IBM VGA 8 font // via http://int10h.org/oldschool-pc-fonts/ // Font License: Creative Commons Attribution-ShareAlike 4.0 International // // converted with a hacked-up version of dewinfont from // http://www.chiark.greenend.org.uk/~sgtatham/fonts/dewinfont // // This could be smaller, etc. with tighter encoding but I wanted // to keep the binascii encoding for easy tweaking. import ( "image" "image/color" "image/draw" "strconv" "unicode/utf8" ) // FontChar is a single character of the font. // The Code field might go away. It was extracted from the original font // and was flaky so I switched to using the UTF8-string to index and locate // glyphs. type FontChar struct { String string // the UTF-8 single character Code uint Width uint Value [16]uint8 } // FontData is a font and its metadata. type FontData struct { Facename string Height int Width int Ascent int Pointsize int Weight int Charset int r2char map[rune]FontChar // index for quick lookup Chars [256]FontChar } // charRow converts a string representation of binary uint8 to uint8. // This is a bit inefficient but it keeps the font easy to edit and // it only needs to run once. func charRow(in string) (out uint8) { for i, r := range in { if r == '1' { out |= (1 << uint8(i)) } } return out } // StringToChars takes a string and returns the FontChar func (fd *FontData) StringToChars(want string) []*FontChar { runes := utf8.RuneCountInString(want) out := make([]*FontChar, runes) errr := fd.r2char['█'] i := 0 for _, r := range want { if c, exists := fd.r2char[r]; exists { out[i] = &c } else { // if the character is unknown, return a full block out[i] = &errr } i++ } return out } // StringToImages takes a string and returns a list of images func (fd *FontData) StringToImages(want string, clr color.Color) []image.Image { chars := fd.StringToChars(want) out := make([]image.Image, len(chars)) rect := image.Rect(0, 0, fd.Width, fd.Height) // TODO: maybe add a field to FontChar to cache or pregenerate the images for i, c := range chars { img := image.NewRGBA(rect) var x uint for y, row := range c.Value { for x = 0; x < c.Width; x++ { if row&uint8(1< max { max = runes } } width := max * fd.Width height := len(want) * fd.Height size := image.Rect(0, 0, width, height) out := image.NewRGBA(size) for y, str := range want { row := fd.StringToImage(str, clr) sr := row.Bounds() dp := image.Pt(0, y*fd.Height) r := image.Rectangle{dp, dp.Add(sr.Size())} draw.Draw(out, r, row, sr.Min, draw.Src) } return out } // FixedFont returns a handle for the embedded 8x16 VGA font. func FixedFont() *FontData { fd := FontData{ Facename: "Bm437 IBM VGA8", Height: 16, Width: 8, Ascent: 12, Pointsize: 12, Weight: 400, Charset: 255, } fd.Chars = [256]FontChar{ FontChar{ String: string('\u0000'), Code: 0x0000, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "☺", Code: 0x0001, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("10000001"), charRow("10100101"), charRow("10000001"), charRow("10000001"), charRow("10111101"), charRow("10011001"), charRow("10000001"), charRow("10000001"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "☻", Code: 0x0002, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("11111111"), charRow("11011011"), charRow("11111111"), charRow("11111111"), charRow("11000011"), charRow("11100111"), charRow("11111111"), charRow("11111111"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♥", Code: 0x0003, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01101100"), charRow("11111110"), charRow("11111110"), charRow("11111110"), charRow("11111110"), charRow("01111100"), charRow("00111000"), charRow("00010000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♦", Code: 0x0004, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01111100"), charRow("11111110"), charRow("01111100"), charRow("00111000"), charRow("00010000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♣", Code: 0x0005, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("00111100"), charRow("11100111"), charRow("11100111"), charRow("11100111"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♠", Code: 0x0006, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("01111110"), charRow("11111111"), charRow("11111111"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "•", Code: 0x0007, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("00111100"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "◘", Code: 0x0008, Width: 8, Value: [16]uint8{ charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11100111"), charRow("11000011"), charRow("11000011"), charRow("11100111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), }, }, FontChar{ String: "○", Code: 0x0009, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("01100110"), charRow("01000010"), charRow("01000010"), charRow("01100110"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♂", Code: 0x000A, Width: 8, Value: [16]uint8{ charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11000011"), charRow("10011001"), charRow("10111101"), charRow("10111101"), charRow("10011001"), charRow("11000011"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), }, }, FontChar{ String: "♀", Code: 0x000B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011110"), charRow("00001110"), charRow("00011010"), charRow("00110010"), charRow("01111000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♀", Code: 0x000C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00011000"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♪", Code: 0x000D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111111"), charRow("00110011"), charRow("00111111"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("01110000"), charRow("11110000"), charRow("11100000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "♫", Code: 0x000E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111111"), charRow("01100011"), charRow("01111111"), charRow("01100011"), charRow("01100011"), charRow("01100011"), charRow("01100011"), charRow("01100111"), charRow("11100111"), charRow("11100110"), charRow("11000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "☼", Code: 0x000F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("11011011"), charRow("00111100"), charRow("11100111"), charRow("00111100"), charRow("11011011"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "►", Code: 0x0010, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("10000000"), charRow("11000000"), charRow("11100000"), charRow("11110000"), charRow("11111000"), charRow("11111110"), charRow("11111000"), charRow("11110000"), charRow("11100000"), charRow("11000000"), charRow("10000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "◄", Code: 0x0011, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000010"), charRow("00000110"), charRow("00001110"), charRow("00011110"), charRow("00111110"), charRow("11111110"), charRow("00111110"), charRow("00011110"), charRow("00001110"), charRow("00000110"), charRow("00000010"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "↕", Code: 0x0012, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00111100"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "‼", Code: 0x0013, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¶", Code: 0x0014, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111111"), charRow("11011011"), charRow("11011011"), charRow("11011011"), charRow("01111011"), charRow("00011011"), charRow("00011011"), charRow("00011011"), charRow("00011011"), charRow("00011011"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "§", Code: 0x0015, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("01100000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("01101100"), charRow("00111000"), charRow("00001100"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "▬", Code: 0x0016, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11111110"), charRow("11111110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "↨", Code: 0x0017, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00111100"), charRow("00011000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "↑", Code: 0x0018, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "↓", Code: 0x0019, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00111100"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "→", Code: 0x001A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00001100"), charRow("11111110"), charRow("00001100"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "←", Code: 0x001B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00110000"), charRow("01100000"), charRow("11111110"), charRow("01100000"), charRow("00110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "∟", Code: 0x001C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "↔", Code: 0x001D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00101000"), charRow("01101100"), charRow("11111110"), charRow("01101100"), charRow("00101000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "▲", Code: 0x001E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("00111000"), charRow("01111100"), charRow("01111100"), charRow("11111110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "▼", Code: 0x001F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11111110"), charRow("01111100"), charRow("01111100"), charRow("00111000"), charRow("00111000"), charRow("00010000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: " ", Code: 0x0020, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "!", Code: 0x0021, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("00111100"), charRow("00111100"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "\"", Code: 0x0022, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00100100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "#", Code: 0x0023, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01101100"), charRow("01101100"), charRow("11111110"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("11111110"), charRow("01101100"), charRow("01101100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "$", Code: 0x0024, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("01111100"), charRow("11000110"), charRow("11000010"), charRow("11000000"), charRow("01111100"), charRow("00000110"), charRow("00000110"), charRow("10000110"), charRow("11000110"), charRow("01111100"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "%", Code: 0x0025, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11000010"), charRow("11000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11000110"), charRow("10000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "&", Code: 0x0026, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("01101100"), charRow("00111000"), charRow("01110110"), charRow("11011100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "'", Code: 0x0027, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("01100000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "(", Code: 0x0028, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: ")", Code: 0x0029, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "*", Code: 0x002A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("00111100"), charRow("11111111"), charRow("00111100"), charRow("01100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "+", Code: 0x002B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: ",", Code: 0x002C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "-", Code: 0x002D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: ".", Code: 0x002E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "/", Code: 0x002F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000010"), charRow("00000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11000000"), charRow("10000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "0", Code: 0x0030, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11010110"), charRow("11010110"), charRow("11000110"), charRow("11000110"), charRow("01101100"), charRow("00111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "1", Code: 0x0031, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00111000"), charRow("01111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "2", Code: 0x0032, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("00000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11000000"), charRow("11000110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "3", Code: 0x0033, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("00000110"), charRow("00000110"), charRow("00111100"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "4", Code: 0x0034, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00001100"), charRow("00011100"), charRow("00111100"), charRow("01101100"), charRow("11001100"), charRow("11111110"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00011110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "5", Code: 0x0035, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11111100"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "6", Code: 0x0036, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("01100000"), charRow("11000000"), charRow("11000000"), charRow("11111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "7", Code: 0x0037, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11000110"), charRow("00000110"), charRow("00000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "8", Code: 0x0038, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "9", Code: 0x0039, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("00001100"), charRow("01111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: ":", Code: 0x003A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: ";", Code: 0x003B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "<", Code: 0x003C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "=", Code: 0x003D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: ">", Code: 0x003E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "?", Code: 0x003F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("00001100"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "@", Code: 0x0040, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11011110"), charRow("11011110"), charRow("11011110"), charRow("11011100"), charRow("11000000"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "A", Code: 0x0041, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11111110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "B", Code: 0x0042, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01111100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("11111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "C", Code: 0x0043, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("01100110"), charRow("11000010"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000010"), charRow("01100110"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "D", Code: 0x0044, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111000"), charRow("01101100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01101100"), charRow("11111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "E", Code: 0x0045, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("01100110"), charRow("01100010"), charRow("01101000"), charRow("01111000"), charRow("01101000"), charRow("01100000"), charRow("01100010"), charRow("01100110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "F", Code: 0x0046, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("01100110"), charRow("01100010"), charRow("01101000"), charRow("01111000"), charRow("01101000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("11110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "G", Code: 0x0047, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("01100110"), charRow("11000010"), charRow("11000000"), charRow("11000000"), charRow("11011110"), charRow("11000110"), charRow("11000110"), charRow("01100110"), charRow("00111010"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "H", Code: 0x0048, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11111110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "I", Code: 0x0049, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "J", Code: 0x004A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011110"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "K", Code: 0x004B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11100110"), charRow("01100110"), charRow("01100110"), charRow("01101100"), charRow("01111000"), charRow("01111000"), charRow("01101100"), charRow("01100110"), charRow("01100110"), charRow("11100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "L", Code: 0x004C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11110000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100010"), charRow("01100110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "M", Code: 0x004D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11101110"), charRow("11111110"), charRow("11111110"), charRow("11010110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "N", Code: 0x004E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11100110"), charRow("11110110"), charRow("11111110"), charRow("11011110"), charRow("11001110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "O", Code: 0x004F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "P", Code: 0x0050, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01111100"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("11110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Q", Code: 0x0051, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11010110"), charRow("11011110"), charRow("01111100"), charRow("00001100"), charRow("00001110"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "R", Code: 0x0052, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01111100"), charRow("01101100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("11100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "S", Code: 0x0053, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("01100000"), charRow("00111000"), charRow("00001100"), charRow("00000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "T", Code: 0x0054, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("01111110"), charRow("01011010"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "U", Code: 0x0055, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "V", Code: 0x0056, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01101100"), charRow("00111000"), charRow("00010000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "W", Code: 0x0057, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11010110"), charRow("11010110"), charRow("11010110"), charRow("11111110"), charRow("11101110"), charRow("01101100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "X", Code: 0x0058, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("01101100"), charRow("01111100"), charRow("00111000"), charRow("00111000"), charRow("01111100"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Y", Code: 0x0059, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Z", Code: 0x005A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11000110"), charRow("10000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11000010"), charRow("11000110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "[", Code: 0x005B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "\\", Code: 0x005C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("10000000"), charRow("11000000"), charRow("11100000"), charRow("01110000"), charRow("00111000"), charRow("00011100"), charRow("00001110"), charRow("00000110"), charRow("00000010"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "]", Code: 0x005D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "^", Code: 0x005E, Width: 8, Value: [16]uint8{ charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "_", Code: 0x005F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "`", Code: 0x0060, Width: 8, Value: [16]uint8{ charRow("00110000"), charRow("00110000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "a", Code: 0x0061, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111000"), charRow("00001100"), charRow("01111100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "b", Code: 0x0062, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11100000"), charRow("01100000"), charRow("01100000"), charRow("01111000"), charRow("01101100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "c", Code: 0x0063, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "d", Code: 0x0064, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011100"), charRow("00001100"), charRow("00001100"), charRow("00111100"), charRow("01101100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "e", Code: 0x0065, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "f", Code: 0x0066, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("01100100"), charRow("01100000"), charRow("11110000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("11110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "g", Code: 0x0067, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01111100"), charRow("00001100"), charRow("11001100"), charRow("01111000"), charRow("00000000"), }, }, FontChar{ String: "h", Code: 0x0068, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11100000"), charRow("01100000"), charRow("01100000"), charRow("01101100"), charRow("01110110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("11100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "i", Code: 0x0069, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "j", Code: 0x006A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000110"), charRow("00000110"), charRow("00000000"), charRow("00001110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00000000"), }, }, FontChar{ String: "k", Code: 0x006B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11100000"), charRow("01100000"), charRow("01100000"), charRow("01100110"), charRow("01101100"), charRow("01111000"), charRow("01111000"), charRow("01101100"), charRow("01100110"), charRow("11100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "l", Code: 0x006C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "m", Code: 0x006D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11101100"), charRow("11111110"), charRow("11010110"), charRow("11010110"), charRow("11010110"), charRow("11010110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "n", Code: 0x006E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11011100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "o", Code: 0x006F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "p", Code: 0x0070, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11011100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01111100"), charRow("01100000"), charRow("01100000"), charRow("11110000"), charRow("00000000"), }, }, FontChar{ String: "q", Code: 0x0071, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01111100"), charRow("00001100"), charRow("00001100"), charRow("00011110"), charRow("00000000"), }, }, FontChar{ String: "r", Code: 0x0072, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11011100"), charRow("01110110"), charRow("01100110"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("11110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "s", Code: 0x0073, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("01100000"), charRow("00111000"), charRow("00001100"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "t", Code: 0x0074, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00010000"), charRow("00110000"), charRow("00110000"), charRow("11111100"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110000"), charRow("00110110"), charRow("00011100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "u", Code: 0x0075, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "v", Code: 0x0076, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "w", Code: 0x0077, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11010110"), charRow("11010110"), charRow("11010110"), charRow("11111110"), charRow("01101100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "x", Code: 0x0078, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("01101100"), charRow("00111000"), charRow("00111000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "y", Code: 0x0079, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111110"), charRow("00000110"), charRow("00001100"), charRow("11111000"), charRow("00000000"), }, }, FontChar{ String: "z", Code: 0x007A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11000110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "{", Code: 0x007B, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00001110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01110000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00001110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "|", Code: 0x007C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "}", Code: 0x007D, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01110000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00001110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "~", Code: 0x007E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11011100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "⌂", Code: 0x007F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Ç", Code: 0x00C7, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("01100110"), charRow("11000010"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000010"), charRow("01100110"), charRow("00111100"), charRow("00001100"), charRow("00000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ü", Code: 0x00FC, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11001100"), charRow("00000000"), charRow("00000000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "é", Code: 0x00E9, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "â", Code: 0x00E2, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("00000000"), charRow("01111000"), charRow("00001100"), charRow("01111100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ä", Code: 0x00E4, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11001100"), charRow("00000000"), charRow("00000000"), charRow("01111000"), charRow("00001100"), charRow("01111100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "à", Code: 0x00E0, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00000000"), charRow("01111000"), charRow("00001100"), charRow("01111100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "å", Code: 0x00E5, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("00111000"), charRow("00000000"), charRow("01111000"), charRow("00001100"), charRow("01111100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ç", Code: 0x00E7, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00111100"), charRow("01100110"), charRow("01100000"), charRow("01100000"), charRow("01100110"), charRow("00111100"), charRow("00001100"), charRow("00000110"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ê", Code: 0x00EA, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ë", Code: 0x00EB, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "è", Code: 0x00E8, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ï", Code: 0x00EF, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "î", Code: 0x00EE, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00011000"), charRow("00111100"), charRow("01100110"), charRow("00000000"), charRow("00111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ì", Code: 0x00EC, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00000000"), charRow("00111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Ä", Code: 0x00C4, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11000110"), charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11111110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Å", Code: 0x00C5, Width: 8, Value: [16]uint8{ charRow("00111000"), charRow("01101100"), charRow("00111000"), charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11111110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "É", Code: 0x00C9, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00000000"), charRow("11111110"), charRow("01100110"), charRow("01100000"), charRow("01111100"), charRow("01100000"), charRow("01100000"), charRow("01100110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "æ", Code: 0x00E6, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11001100"), charRow("01110110"), charRow("00110110"), charRow("01111110"), charRow("11011000"), charRow("11011000"), charRow("01101110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Æ", Code: 0x00C6, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111110"), charRow("01101100"), charRow("11001100"), charRow("11001100"), charRow("11111110"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ô", Code: 0x00F4, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00010000"), charRow("00111000"), charRow("01101100"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ö", Code: 0x00F6, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ò", Code: 0x00F2, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "û", Code: 0x00FB, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00110000"), charRow("01111000"), charRow("11001100"), charRow("00000000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ù", Code: 0x00F9, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00000000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ÿ", Code: 0x00FF, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111110"), charRow("00000110"), charRow("00001100"), charRow("01111000"), charRow("00000000"), }, }, FontChar{ String: "Ö", Code: 0x00D6, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11000110"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Ü", Code: 0x00DC, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11000110"), charRow("00000000"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¢", Code: 0x00A2, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("01100110"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100110"), charRow("00111100"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "£", Code: 0x00A3, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("01100100"), charRow("01100000"), charRow("11110000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("11100110"), charRow("11111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¥", Code: 0x00A5, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00011000"), charRow("01111110"), charRow("00011000"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "₧", Code: 0x20A7, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11111000"), charRow("11001100"), charRow("11001100"), charRow("11111000"), charRow("11000100"), charRow("11001100"), charRow("11011110"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ƒ", Code: 0x0192, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00001110"), charRow("00011011"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11011000"), charRow("01110000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "á", Code: 0x00E1, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00000000"), charRow("01111000"), charRow("00001100"), charRow("01111100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "í", Code: 0x00ED, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("00000000"), charRow("00111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ó", Code: 0x00F3, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ú", Code: 0x00FA, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00000000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ñ", Code: 0x00F1, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11011100"), charRow("00000000"), charRow("11011100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Ñ", Code: 0x00D1, Width: 8, Value: [16]uint8{ charRow("01110110"), charRow("11011100"), charRow("00000000"), charRow("11000110"), charRow("11100110"), charRow("11110110"), charRow("11111110"), charRow("11011110"), charRow("11001110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ª", Code: 0x00AA, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00111100"), charRow("01101100"), charRow("01101100"), charRow("00111110"), charRow("00000000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "º", Code: 0x00BA, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("01101100"), charRow("00111000"), charRow("00000000"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¿", Code: 0x00BF, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00110000"), charRow("00110000"), charRow("00000000"), charRow("00110000"), charRow("00110000"), charRow("01100000"), charRow("11000000"), charRow("11000110"), charRow("11000110"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "⌐", Code: 0x2310, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¬", Code: 0x00AC, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("00000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "½", Code: 0x00BD, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11000000"), charRow("11000000"), charRow("11000010"), charRow("11000110"), charRow("11001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11011100"), charRow("10000110"), charRow("00001100"), charRow("00011000"), charRow("00111110"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¼", Code: 0x00BC, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11000000"), charRow("11000000"), charRow("11000010"), charRow("11000110"), charRow("11001100"), charRow("00011000"), charRow("00110000"), charRow("01100110"), charRow("11001110"), charRow("10011110"), charRow("00111110"), charRow("00000110"), charRow("00000110"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "¡", Code: 0x00A1, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00111100"), charRow("00111100"), charRow("00111100"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "«", Code: 0x00AB, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00110110"), charRow("01101100"), charRow("11011000"), charRow("01101100"), charRow("00110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "»", Code: 0x00BB, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11011000"), charRow("01101100"), charRow("00110110"), charRow("01101100"), charRow("11011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "░", Code: 0x2591, Width: 8, Value: [16]uint8{ charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), charRow("00010001"), charRow("01000100"), }, }, FontChar{ String: "▒", Code: 0x2592, Width: 8, Value: [16]uint8{ charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), charRow("01010101"), charRow("10101010"), }, }, FontChar{ String: "▓", Code: 0x2593, Width: 8, Value: [16]uint8{ charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), charRow("11011101"), charRow("01110111"), }, }, FontChar{ String: "│", Code: 0x2502, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "┤", Code: 0x2524, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╡", Code: 0x2561, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111000"), charRow("00011000"), charRow("11111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╢", Code: 0x2562, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╖", Code: 0x2556, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╕", Code: 0x2555, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111000"), charRow("00011000"), charRow("11111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╣", Code: 0x2563, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11110110"), charRow("00000110"), charRow("11110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "║", Code: 0x2551, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╗", Code: 0x2557, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00000110"), charRow("11110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╝", Code: 0x255D, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11110110"), charRow("00000110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╜", Code: 0x255C, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╛", Code: 0x255B, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111000"), charRow("00011000"), charRow("11111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "┐", Code: 0x2510, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "└", Code: 0x2514, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "┴", Code: 0x2534, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "┬", Code: 0x252C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "├", Code: 0x251C, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "─", Code: 0x2500, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "┼", Code: 0x253C, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╞", Code: 0x255E, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011111"), charRow("00011000"), charRow("00011111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╟", Code: 0x255F, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╚", Code: 0x255A, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110111"), charRow("00110000"), charRow("00111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╔", Code: 0x2554, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00111111"), charRow("00110000"), charRow("00110111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╩", Code: 0x2569, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11110111"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╦", Code: 0x2566, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("11110111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╠", Code: 0x2560, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110111"), charRow("00110000"), charRow("00110111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "═", Code: 0x2550, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╬", Code: 0x256C, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11110111"), charRow("00000000"), charRow("11110111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╧", Code: 0x2567, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111111"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╨", Code: 0x2568, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╤", Code: 0x2564, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("11111111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╥", Code: 0x2565, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╙", Code: 0x2559, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╘", Code: 0x2558, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011111"), charRow("00011000"), charRow("00011111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "╒", Code: 0x2552, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011111"), charRow("00011000"), charRow("00011111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "╓", Code: 0x2553, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00111111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╫", Code: 0x256B, Width: 8, Value: [16]uint8{ charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("11111111"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), charRow("00110110"), }, }, FontChar{ String: "╪", Code: 0x256A, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111111"), charRow("00011000"), charRow("11111111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "┘", Code: 0x2518, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "┌", Code: 0x250C, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011111"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "█", Code: 0x2588, Width: 8, Value: [16]uint8{ charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), }, }, FontChar{ String: "▄", Code: 0x2584, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), }, }, FontChar{ String: "▌", Code: 0x258C, Width: 8, Value: [16]uint8{ charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), charRow("11110000"), }, }, FontChar{ String: "▐", Code: 0x2590, Width: 8, Value: [16]uint8{ charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), charRow("00001111"), }, }, FontChar{ String: "▀", Code: 0x2580, Width: 8, Value: [16]uint8{ charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "α", Code: 0x03B1, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11011100"), charRow("11011000"), charRow("11011000"), charRow("11011000"), charRow("11011100"), charRow("01110110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ß", Code: 0x00DF, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("01111000"), charRow("11001100"), charRow("11001100"), charRow("11001100"), charRow("11011000"), charRow("11001100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11001100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Γ", Code: 0x0393, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11000110"), charRow("11000110"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("11000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "π", Code: 0x03C0, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Σ", Code: 0x03A3, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("11000110"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("11000110"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "σ", Code: 0x03C3, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("11011000"), charRow("11011000"), charRow("11011000"), charRow("11011000"), charRow("11011000"), charRow("01110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "µ", Code: 0x00B5, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01111100"), charRow("01100000"), charRow("01100000"), charRow("11000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "τ", Code: 0x03C4, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11011100"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Φ", Code: 0x03A6, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("00011000"), charRow("00111100"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00011000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Θ", Code: 0x0398, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11111110"), charRow("11000110"), charRow("11000110"), charRow("01101100"), charRow("00111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "Ω", Code: 0x03A9, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("11101110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "δ", Code: 0x03B4, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011110"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00111110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("01100110"), charRow("00111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "∞", Code: 0x221E, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111110"), charRow("11011011"), charRow("11011011"), charRow("11011011"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "φ", Code: 0x03C6, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000011"), charRow("00000110"), charRow("01111110"), charRow("11011011"), charRow("11011011"), charRow("11110011"), charRow("01111110"), charRow("01100000"), charRow("11000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ε", Code: 0x03B5, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00011100"), charRow("00110000"), charRow("01100000"), charRow("01100000"), charRow("01111100"), charRow("01100000"), charRow("01100000"), charRow("01100000"), charRow("00110000"), charRow("00011100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "∩", Code: 0x2229, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("11000110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "≡", Code: 0x2261, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("11111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "±", Code: 0x00B1, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("01111110"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("11111111"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "≥", Code: 0x2265, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00000110"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("00000000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "≤", Code: 0x2264, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00001100"), charRow("00011000"), charRow("00110000"), charRow("01100000"), charRow("00110000"), charRow("00011000"), charRow("00001100"), charRow("00000000"), charRow("01111110"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "⌠", Code: 0x2320, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00001110"), charRow("00011011"), charRow("00011011"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), }, }, FontChar{ String: "⌡", Code: 0x2321, Width: 8, Value: [16]uint8{ charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("00011000"), charRow("11011000"), charRow("11011000"), charRow("11011000"), charRow("01110000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "÷", Code: 0x00F7, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("01111110"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "≈", Code: 0x2248, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01110110"), charRow("11011100"), charRow("00000000"), charRow("01110110"), charRow("11011100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "°", Code: 0x00B0, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00111000"), charRow("01101100"), charRow("01101100"), charRow("00111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "∙", Code: 0x2219, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "·", Code: 0x00B7, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00011000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "√", Code: 0x221A, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00001111"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("00001100"), charRow("11101100"), charRow("01101100"), charRow("01101100"), charRow("00111100"), charRow("00011100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "ⁿ", Code: 0x207F, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("11011000"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("01101100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "²", Code: 0x00B2, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("01110000"), charRow("11011000"), charRow("00110000"), charRow("01100000"), charRow("11001000"), charRow("11111000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: "■", Code: 0x25A0, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("01111100"), charRow("01111100"), charRow("01111100"), charRow("01111100"), charRow("01111100"), charRow("01111100"), charRow("01111100"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, FontChar{ String: string('\u00A0'), Code: 0x00A0, Width: 8, Value: [16]uint8{ charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), charRow("00000000"), }, }, } // most of the use is going from a unicode rune to font char data fd.r2char = make(map[rune]FontChar) for _, c := range fd.Chars { r, _ := utf8.DecodeRuneInString(c.String) fd.r2char[r] = c } return &fd } // ParseColor parses a 6-byte or 8-byte hex color string (HTML-style) and // returns a color.RGBA. If anything goes wrong during parsing, it returns // the default provided. func (fd *FontData) ParseColor(in string, def color.Color) color.Color { if len(in) != 6 && len(in) != 8 { return def } var r, g, b, a uint8 if r32, err := strconv.ParseInt("0x"+in[0:2], 0, 32); err == nil { r = uint8(r32) } else { log.Println(err) return def } if g32, err := strconv.ParseInt("0x"+in[2:4], 0, 32); err == nil { g = uint8(g32) } else { log.Println(err) return def } if b32, err := strconv.ParseInt("0x"+in[4:6], 0, 32); err == nil { b = uint8(b32) } else { log.Println(err) return def } if len(in) == 8 { if a32, err := strconv.ParseInt("0x"+in[6:8], 0, 32); err == nil { a = uint8(a32) } else { log.Println(err) return def } } else { a = 255 } return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)} } ================================================ FILE: hal/text2image_test.go ================================================ package hal_test /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "github.com/netflix/hal-9001/hal" "image/color" "testing" ) func Testtext2image(t *testing.T) { def := color.RGBA{R: 1, G: 1, B: 1, A: 1} samples := map[string][4]uint32{ "ffffff": [4]uint32{255, 255, 255, 255}, "ffffffff": [4]uint32{255, 255, 255, 255}, "000000ff": [4]uint32{0, 0, 0, 255}, "000000aa": [4]uint32{0, 0, 0, 170}, "88888888": [4]uint32{136, 136, 136, 136}, "888888": [4]uint32{136, 136, 136, 255}, "f79e10": [4]uint32{247, 158, 16, 255}, "f79e10ff": [4]uint32{247, 158, 16, 255}, } fd := hal.FixedFont() for str, expected := range samples { result := fd.ParseColor(str, def) t.Logf("%q => %q\n", str, result) r, g, b, a := result.RGBA() if r != expected[0] { t.Fail() } if g != expected[1] { t.Fail() } if b != expected[2] { t.Fail() } if a != expected[3] { t.Fail() } } } ================================================ FILE: hal/ttlcache.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "errors" "fmt" "reflect" "sync" "time" ) type ttlCache struct { items map[string]interface{} times map[string]time.Time ttls map[string]time.Duration mut sync.Mutex // concurrent access init sync.Once // initialize the singleton once } var ttlcache ttlCache func Cache() *ttlCache { ttlcache.init.Do(func() { ttlcache.items = make(map[string]interface{}) ttlcache.times = make(map[string]time.Time) ttlcache.ttls = make(map[string]time.Duration) }) return &ttlcache } // Get retreives a cached value and stores the result in the value pointed to by v. // The time to live is returned and may be 0 to indicate the item is expired. // e.g. // policies := []EscalationPolicy{} // err = hal.Cache().Set("pagerduty.escalation_policies", &policies, time.Hour) // ttl, err := hal.Cache().Get("pagerduty.escalation_policies", &policies) // if err != nil { panic(err) } // if ttl == 0 { panic("stale cache!") } func (cache *ttlCache) Get(key string, v interface{}) (time.Duration, error) { cache.mut.Lock() defer cache.mut.Unlock() ttl := time.Duration(0) age := time.Now().Sub(cache.times[key]) if age.Seconds() < cache.ttls[key].Seconds() { // not expired, compute the ttl ttlsecs := cache.ttls[key].Seconds() - age.Seconds() ttl = time.Duration(int(ttlsecs)) * time.Second } cached := cache.items[key] vtype := reflect.TypeOf(v) ctype := reflect.TypeOf(cached) // make sure the input type matches the type in the cache if vtype != ctype { msg := fmt.Sprintf("Type mismatch: got %q, expected %q", vtype.Name(), ctype.Name()) return ttl, errors.New(msg) } // make sure it's a pointer and is not nil vval := reflect.ValueOf(v) if vval.Kind() != reflect.Ptr || vval.IsNil() { return ttl, errors.New("The second argument of Get() must be a non-nil pointer.") } // set the value cval := reflect.ValueOf(cached) vval.Elem().Set(cval.Elem()) return ttl, nil } func (cache *ttlCache) Set(key string, v interface{}, ttl time.Duration) { cache.mut.Lock() defer cache.mut.Unlock() cache.items[key] = v cache.times[key] = time.Now() cache.ttls[key] = ttl } func (cache *ttlCache) Delete(key string) { cache.mut.Lock() defer cache.mut.Unlock() delete(cache.items, key) delete(cache.times, key) delete(cache.ttls, key) } func (cache *ttlCache) Exists(key string) bool { cache.mut.Lock() defer cache.mut.Unlock() _, exists := cache.ttls[key] return exists } // Age returns the age as time.Duration for the given key. Returns duration 0 // for keys that don't exist. func (cache *ttlCache) Age(key string) time.Duration { cache.mut.Lock() defer cache.mut.Unlock() if val, exists := cache.times[key]; exists { return time.Now().Sub(val) } return time.Duration(0) } // Ttl returns the time-to-live for the given key. Returns a TTL of 0 for // keys that don't exist. func (cache *ttlCache) Ttl(key string) time.Duration { cache.mut.Lock() defer cache.mut.Unlock() if val, exists := cache.ttls[key]; exists { return val } return time.Duration(0) } ================================================ FILE: hal/ttlcache_test.go ================================================ package hal_test /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "testing" "time" "github.com/netflix/hal-9001/hal" ) type Whatever struct { Field1 string Field2 int Field3 map[string]string } func TestTtlCache(t *testing.T) { w1 := Whatever{ Field1: "testing", Field2: 9, Field3: map[string]string{"foo": "bar"}, } cache := hal.Cache() cache.Set("whatever", &w1, time.Hour*24) w2 := Whatever{} ttl, err := cache.Get("whatever", &w2) if err != nil { panic(err) } if ttl == 0 { t.Error("ttl expired way too early") t.Fail() } if w2.Field2 != w1.Field2 { t.Error("Field2 doesn't match") t.Fail() } if w2.Field3["foo"] != "bar" { t.Error("Field3 doesn't match") t.Fail() } } ================================================ FILE: hal/utf8table.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bytes" "fmt" "strings" ) // Utf8Table is like AsciiTable but it uses UTF8 characters for the table // borders. It should look nice when rendered to an image by Hal's text->img. func Utf8Table(hdr []string, rows [][]string) string { if len(rows) == 0 { return "NO DATA TO DISPLAY" } else if len(rows[0]) == 0 { panic("BUG: the first row seems to be empty!") } // find the needed width of each column colwidths := make([]int, len(hdr)) // start with the headers' widths for j, col := range hdr { colwidths[j] = len(col) } // bump to the size of any larger cells for i, row := range rows { // handle empty/short rows gracefully by reallocating which // results in a default value of "" if len(row) < len(hdr) { newrow := make([]string, len(hdr)) copy(newrow[0:len(row)], row) rows[i] = newrow row = newrow } for j, col := range row { if colwidths[j] < len(col) { colwidths[j] = len(col) } } } // generate format strings for the columns fmts := make([]string, len(colwidths)) hdrtop := make([]string, len(colwidths)) // top frame, above header row hdrbot := make([]string, len(colwidths)) // top frame, below header row tblbot := make([]string, len(colwidths)) // bottom of table /* some unicode references - these are available in Hal's image renderer ╔═════╦═════╗ ╔═══╤═══╗ ╔══╤══╤══╗ ║ ║ ║ ╟───┼───╢ ╟──┼──┼──╢ ╚═════╩═════╝ ╚═══╧═══╝ ╚══╧══╧══╝ */ if len(colwidths) > 1 { for i, width := range colwidths { if i == 0 { // first column fmts[i] = fmt.Sprintf("║ %%%ds │", width) hdrtop[i] = fmt.Sprintf("╔%s╤", strings.Repeat("═", width+2)) hdrbot[i] = fmt.Sprintf("╟%s┼", strings.Repeat("─", width+2)) tblbot[i] = fmt.Sprintf("╚%s╧", strings.Repeat("═", width+2)) } else if i == len(colwidths)-1 { // last column fmts[i] = fmt.Sprintf(" %%%ds ║\n", width) hdrtop[i] = fmt.Sprintf("%s╗\n", strings.Repeat("═", width+2)) hdrbot[i] = fmt.Sprintf("%s╢\n", strings.Repeat("─", width+2)) tblbot[i] = fmt.Sprintf("%s╝\n", strings.Repeat("═", width+2)) } else { // inner columns fmts[i] = fmt.Sprintf(" %%%ds │", width) hdrtop[i] = fmt.Sprintf("%s╤", strings.Repeat("═", width+2)) hdrbot[i] = fmt.Sprintf("%s┼", strings.Repeat("─", width+2)) tblbot[i] = fmt.Sprintf("%s╧", strings.Repeat("═", width+2)) } } } else { // single-column tables fmts[0] = fmt.Sprintf("║ %%%ds ║\n", colwidths[0]) hdrtop[0] = fmt.Sprintf("╔%s╗\n", strings.Repeat("═", colwidths[0]+2)) hdrbot[0] = fmt.Sprintf("╟%s╢\n", strings.Repeat("─", colwidths[0]+2)) tblbot[0] = fmt.Sprintf("╚%s╝\n", strings.Repeat("═", colwidths[0]+2)) } buf := bytes.NewBuffer([]byte{}) // top of header (frame) // ╔══════╤═══════╗ fmt.Fprint(buf, strings.Join(hdrtop, "")) // header columns (text + frame) // ║ left │ right ║ for j, col := range hdr { fmt.Fprintf(buf, fmts[j], col) } // between header & data (frame) // ╟──────┼───────╢ fmt.Fprintf(buf, strings.Join(hdrbot, "")) // data rows // ║ one │ three ║ // ║ two │ ║ for _, row := range rows { for j, col := range row { fmt.Fprintf(buf, fmts[j], col) } } // bottom frame // ╚══════╧═══════╝ fmt.Fprintf(buf, strings.Join(tblbot, "")) return buf.String() } ================================================ FILE: hal/utf8table_test.go ================================================ package hal /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "strings" "testing" ) func TestUtf8Table(t *testing.T) { samples := [][][]string{ { {"hdr"}, {"one"}, }, { {"hdr"}, {"one"}, {"two"}, }, { {"left", "right"}, {"one", "three"}, {"two"}, }, { {"HEADER 1", "HDR 2", "LOL WUT"}, {"one", "two", "three"}, {"four", "five", "six"}, }, { {"Col 1", "Col 2", "3rd Column", "4th", "FIFTH"}, {"one", "two", "three"}, {"four", "five", "six"}, {"hi"}, {"", "", "", "-", "+"}, }, } var results [5]string results[0] = ` ╔═════╗ ║ hdr ║ ╟─────╢ ║ one ║ ╚═════╝ ` results[1] = ` ╔═════╗ ║ hdr ║ ╟─────╢ ║ one ║ ║ two ║ ╚═════╝ ` results[2] = ` ╔══════╤═══════╗ ║ left │ right ║ ╟──────┼───────╢ ║ one │ three ║ ║ two │ ║ ╚══════╧═══════╝ ` results[3] = ` ╔══════════╤═══════╤═════════╗ ║ HEADER 1 │ HDR 2 │ LOL WUT ║ ╟──────────┼───────┼─────────╢ ║ one │ two │ three ║ ║ four │ five │ six ║ ╚══════════╧═══════╧═════════╝ ` results[4] = ` ╔═══════╤═══════╤════════════╤═════╤═══════╗ ║ Col 1 │ Col 2 │ 3rd Column │ 4th │ FIFTH ║ ╟───────┼───────┼────────────┼─────┼───────╢ ║ one │ two │ three │ │ ║ ║ four │ five │ six │ │ ║ ║ hi │ │ │ │ ║ ║ │ │ │ - │ + ║ ╚═══════╧═══════╧════════════╧═════╧═══════╝ ` for i, sample := range samples { // first row is the header, the rest is data rows out := Utf8Table(sample[0], sample[1:]) if len(out) == 0 { t.Fail() } trout := strings.TrimSpace(out) trres := strings.TrimSpace(results[i]) if trout != trres { t.Logf("Got: \n%s\nExpected:\n%s\n", trout, trres) t.Fail() } } } ================================================ FILE: plugins/archive/plugin.go ================================================ package archive /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "fmt" "net/http" "strings" "time" "github.com/netflix/hal-9001/hal" "github.com/nlopes/slack" ) var log hal.Logger // ArchiveEntry is a single event observed by the archive plugin. type ArchiveEntry struct { ID string `json:"id"` Timestamp time.Time `json:"timestamp"` User string `json:"user"` Room string `json:"room"` Broker string `json:"broker"` Body string `json:"body"` Reactions []string `json:"reactions"` } // ArchiveTable stores events for posterity. // The brokers currently supported do not provide a surrogate event id // and instead rely on the timestamp/user/room for identity. const ArchiveTable = ` CREATE TABLE IF NOT EXISTS archive ( id VARCHAR(191), user VARCHAR(191), room VARCHAR(191), broker VARCHAR(191), ts TIMESTAMP, body TEXT, PRIMARY KEY (id,user,room,broker) )` const ReactionTable = ` CREATE TABLE IF NOT EXISTS reactions ( id VARCHAR(191), user VARCHAR(191), room VARCHAR(191), broker VARCHAR(191), ts TIMESTAMP, reaction VARCHAR(191), PRIMARY KEY (ts,user,room,broker) )` func Register() { archive := hal.Plugin{ Name: "message_archive", Func: archiveRecorder, BotEvents: true, } archive.Register() reactions := hal.Plugin{ Name: "reaction_tracker", Func: archiveReaction, BotEvents: true, } reactions.Register() // apply the schema to the database as necessary hal.SqlInit(ArchiveTable) hal.SqlInit(ReactionTable) http.HandleFunc("/v1/archive", httpGetArchive) } // ArchiveRecorder inserts every message received into the database for use // by other parts of the system. func archiveRecorder(evt hal.Evt) { // ignore non-chat events for the archive (e.g. reaction added, etc.) if !evt.IsChat { return } // ignore bot commands prefixed with ! if strings.HasPrefix(strings.TrimSpace(evt.Body), "!") { return } sql := `INSERT INTO archive (id, user, room, broker, ts, body) VALUES (?, ?, ?, ?, ?, ?)` _, err := hal.SqlDB().Exec(sql, evt.ID, evt.UserId, evt.RoomId, evt.BrokerName(), evt.Time, evt.Body) if err != nil { log.Printf("Could not insert event into archive: %s\n", err) } } // archiveReactionAdded switches on the type of the original message and calls a // broker-specific function to pull out the reaction and write it to the database. func archiveReaction(evt hal.Evt) { // ignore events marked as chats since they can't be reactions if evt.IsChat { return } switch evt.Original.(type) { case *slack.ReactionAddedEvent: log.Printf("adding reaction: (%T) %q\n", evt.Original, evt.Body) rae := evt.Original.(*slack.ReactionAddedEvent) insertReaction(evt.Time, rae.Item.Timestamp, evt.UserId, evt.RoomId, evt.BrokerName(), rae.Reaction) case *slack.ReactionRemovedEvent: log.Printf("deleting reaction: (%T) %q\n", evt.Original, evt.Body) rre := evt.Original.(*slack.ReactionRemovedEvent) // TODO: handle files & file comments deleteReaction(rre.Item.Timestamp, evt.UserId, rre.Item.Channel, evt.BrokerName(), rre.Reaction) default: return } } func insertReaction(ts time.Time, id, user, room, broker, reaction string) { sql := `INSERT INTO reactions (id,user,room,broker,ts,reaction) VALUES (?,?,?,?,?,?)` _, err := hal.SqlDB().Exec(sql, id, user, room, broker, ts, reaction) if err != nil { log.Printf("Could not insert reaction into reactions table: %s\n", err) } } func deleteReaction(id, user, room, broker, reaction string) { sql := `DELETE FROM reactions WHERE id=? AND user=? AND room=? AND broker=? AND reaction=?` _, err := hal.SqlDB().Exec(sql, id, user, room, broker, reaction) if err != nil { log.Printf("Could not delete reaction from reactions table: %s\n", err) } } // httpGetArchive retreives the 50 latest items from the event archive. func httpGetArchive(w http.ResponseWriter, r *http.Request) { aes, err := FetchArchive(50) if err != nil { http.Error(w, fmt.Sprintf("could not fetch message archive: '%s'", err), 500) return } js, err := json.Marshal(aes) if err != nil { http.Error(w, fmt.Sprintf("could not marshal archive to json: '%s'", err), 500) return } w.Write(js) } // FetchArchive selects messages from the archive table up to the provided number of messages limit. func FetchArchive(limit int) ([]*ArchiveEntry, error) { db := hal.SqlDB() // joining reactions in here for now - might be better to let the client do it // but for now get something working // This pulls back multiple rows if there are multiple reactions. The row iteration // below uses a map to dedupe the archive rows and put reactions into a list. // This might be better written with GROUP_CONCAT later... sql := `SELECT a.id AS id, UNIX_TIMESTAMP(a.ts) AS ts, a.user AS user, a.room AS room, a.broker AS broker, a.body AS body, IFNULL(r.reaction,"") AS reaction FROM archive a LEFT OUTER JOIN reactions r ON ( r.id = a.id AND r.room = a.room ) WHERE a.ts < ? AND a.ts > ? GROUP BY a.id ORDER BY a.ts DESC` now := time.Now() yesterday := now.Add(-time.Hour * 24) rows, err := db.Query(sql, &now, &yesterday) if err != nil { log.Printf("archive query failed: %s\n", err) return nil, err } defer rows.Close() entries := make(map[string]*ArchiveEntry) for rows.Next() { var ts int64 var id, room, user, broker, body, reaction string err = rows.Scan(&id, &ts, &user, &room, &broker, &body, &reaction) if err != nil { log.Printf("Row iteration failed: %s\n", err) return nil, err } if entry, exists := entries[id]; exists { if reaction != "" { entry.Reactions = append(entry.Reactions, reaction) } } else { ae := ArchiveEntry{ ID: id, Timestamp: time.Unix(ts, 0), Broker: broker, Body: body, Reactions: []string{}, } if reaction != "" { ae.Reactions = append(ae.Reactions, reaction) } // convert ids to names broker := hal.Router().GetBroker(ae.Broker) ae.Room = broker.RoomIdToName(room) ae.User = broker.UserIdToName(user) entries[id] = &ae } } // hmm might want to sort these before sending... aes := make([]*ArchiveEntry, len(entries)) var i int for _, ae := range entries { aes[i] = ae i++ } return aes, nil } ================================================ FILE: plugins/blabber/plugin.go ================================================ // blabber records events as word pairs with counts and can // use that data to generate text // This is an experiment and work-in-progress. I haven't built // one of these before... package blabber /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bytes" "fmt" "math/rand" "strings" "github.com/netflix/hal-9001/hal" ) var log hal.Logger type wncRow struct { word string next string count int } type qFrag struct { empty bool sql string params []interface{} } // a table tracking words said in the attached chatroom, hopefully // suitable for quick & dirty markov-style chatterbot stuff const BLABBERWORDS_TABLE = ` CREATE TABLE IF NOT EXISTS blabberwords ( word VARCHAR(191), -- the word seen in the room user VARCHAR(191), -- the user who said it room VARCHAR(191), -- the chat room it was said in next VARCHAR(191), -- the word after it count int, -- how many times this pair has been seen ts TIMESTAMP, -- when it was last said (not indexed for now) PRIMARY KEY (word, user, room, next) )` func Register() { bw := hal.Plugin{ Name: "blabberwords", Func: bwCounter, } bw.Register() bb := hal.Plugin{ Name: "blab", Func: blab, Command: "blab", } bb.Register() // apply the schema to the database as necessary hal.SqlInit(BLABBERWORDS_TABLE) } func bwCounter(evt hal.Evt) { parts := evt.BodyAsArgv() // ignore really short lines or commands // TODO: ignore the bot too and add prefs for things to ignore if len(parts) < 2 || strings.HasPrefix(strings.TrimSpace(parts[0]), "!") { return } db := hal.SqlDB() sql := `INSERT INTO blabberwords (word,user,room,next,count) VALUES (?, ?, ?, ?, 1) ON DUPLICATE KEY UPDATE count=values(count) + 1` query, err := db.Prepare(sql) if err != nil { log.Printf("Could not prepare insert query: %s", err) return } for i, word := range parts { next := "" // first word will have word="", next="first" // last word will have word="whatever", next="" if i == 0 { next = word word = "" } else if i < len(parts)-1 { next = parts[i+1] } tword := strings.TrimRight(word, ".?!") tnext := strings.TrimRight(next, ".?!") _, err = query.Exec(tword, evt.User, evt.Room, tnext) if err != nil { log.Printf("prepared insert into blabberwords failed: %s", err) continue } } } // !blab --user atobey // !blab --user atobey --room incidents // !blab --room incidents // !blab --user atobey,dhahn,jhorowitz ??? // !blab --user dhahn // TODO: figure out a non-insane way to build a sentence around a specific word or words func blab(evt hal.Evt) { users := []string{} rooms := []string{} argv := evt.BodyAsArgv() for i, arg := range argv { switch arg { case "--user": found := extractArgs(argv, i) users = append(users, found...) case "--room": found := extractArgs(argv, i) rooms = append(rooms, found...) } } userFrag := mkQueryFragment("user", users) roomFrag := mkQueryFragment("room", rooms) // start with a random first word given the provided constraints first := firstWord(userFrag, roomFrag) words := []wncRow{first} for { next := nextWord(words[len(words)-1], userFrag, roomFrag) words = append(words, next) log.Printf("BLAB: %+v", words) // found a last word if next.next == "" { break } // stop trying after 20 words if len(words) > 20 { break } } evt.Reply(rowsToString(words)) } // for now, completely random, will add in probability later... func nextWord(current wncRow, userFrag, roomFrag qFrag) wncRow { sqlbuf := bytes.NewBufferString("SELECT word,next,count FROM blabberwords WHERE word=? ") params := []interface{}{current.next} if !userFrag.empty { sqlbuf.WriteString(" AND ") sqlbuf.WriteString(userFrag.sql) params = append(params, userFrag.params...) } if !roomFrag.empty { sqlbuf.WriteString(" AND ") sqlbuf.WriteString(roomFrag.sql) params = append(params, roomFrag.params...) } rows := getRows(sqlbuf.String(), params) if len(rows) == 0 { log.Printf("blabber.nextWord got 0 rows, returning empty row") return wncRow{"", "", 0} } idx := rand.Intn(len(rows) - 1) return rows[idx] } func rowsToString(rows []wncRow) string { words := make([]string, len(rows)) for i, val := range rows { words[i] = val.word } return strings.Join(words, " ") } func getRows(sql string, params []interface{}) []wncRow { db := hal.SqlDB() log.Printf("Running query: %q\n%+v\n", sql, params) rows, err := db.Query(sql, params...) if err != nil { log.Printf("blabberwords query %q failed: %s", sql, err) return []wncRow{} } wncs := []wncRow{} for rows.Next() { wnc := wncRow{} err = rows.Scan(&wnc.word, &wnc.next, &wnc.count) if err != nil { log.Printf("blabberwords query scan failed: %s", err) return wncs } wncs = append(wncs, wnc) } return wncs } func firstWord(userFrag, roomFrag qFrag) wncRow { sqlbuf := bytes.NewBufferString("SELECT word,next,count FROM blabberwords WHERE word='' ") params := []interface{}{} if !userFrag.empty { sqlbuf.WriteString(" AND ") sqlbuf.WriteString(userFrag.sql) params = append(params, userFrag.params...) } if !roomFrag.empty { sqlbuf.WriteString(" AND ") sqlbuf.WriteString(roomFrag.sql) params = append(params, roomFrag.params...) } // will get back a list (potentially large) of candidates wncs := getRows(sqlbuf.String(), params) // when now rows are returned, just say "FAIL" if len(wncs) == 0 { return wncRow{"FAIL", "", 0} } idx := rand.Intn(len(wncs) - 1) return wncs[idx] } func mkQueryFragment(col string, list []string) qFrag { if len(list) == 0 { return qFrag{true, "", []interface{}{}} } params := make([]interface{}, len(list)) frags := make([]string, len(list)) for i, item := range list { frags[i] = fmt.Sprintf("%s=?", col) params[i] = item } sql := " ( " + strings.Join(frags, " OR ") + " ) " return qFrag{false, sql, params} } func extractArgs(argv []string, i int) []string { out := []string{} // out of bounds, nothing to do if i < len(argv)-2 { return out } clean := strings.Replace(argv[i+1], " ", "", -1) return strings.Split(clean, ",") } ================================================ FILE: plugins/cross_the_streams/plugin.go ================================================ // cross_the_streams replicates messages between brokers package cross_the_streams /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "github.com/netflix/hal-9001/hal" ) var log hal.Logger // Register makes this plugin available to the system. func Register() { plugin := hal.Plugin{ Name: "cross_the_streams", Func: crossStreams, // source: Pref.Room / Pref.Broker Settings: hal.Prefs{ hal.Pref{Plugin: "cross_the_streams", Key: "to.broker"}, hal.Pref{Plugin: "cross_the_streams", Key: "to.room"}, }, } plugin.Register() } // crossStreams looks at events it recieves and repeats them // to a different broker. func crossStreams(evt hal.Evt) { prefs := evt.InstanceSettings() tbPrefs := prefs.Key("to.broker") trPrefs := prefs.Key("to.room") // no matches, move on if len(tbPrefs) == 0 || len(trPrefs) == 0 { return } toBroker := tbPrefs[0].Value toRoomId := trPrefs[0].Value tb := hal.Router().GetBroker(toBroker) if tb != nil { toRoom := tb.RoomIdToName(toRoomId) body := fmt.Sprintf("%s %s@%s: %s", evt.Time, evt.User, evt.Room, evt.Body) out := hal.Evt{ Body: body, Room: toRoom, RoomId: toRoomId, Time: evt.Time, Broker: tb, } tb.Send(out) } else { log.Printf("hal.Router does not know about a broker named %q", toBroker) } } ================================================ FILE: plugins/docker/plugin.go ================================================ // Package docker allows users to attach a Docker image to a room and interact // with it over its stdin/stdout. package docker /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "os/exec" "github.com/netflix/hal-9001/hal" ) const Name = "docker" const Usage = ` Examples: !docker images !docker run ` // Register makes this plugin available to the system. func Register() { plugin := hal.Plugin{ Name: Name, Func: docker, Command: "docker", } plugin.Register() } func docker(evt hal.Evt) { argv := evt.BodyAsArgv() if len(argv) < 2 { evt.Reply(Usage) return } switch argv[1] { case "images": images(evt) case "run": if len(argv) < 3 { evt.Replyf("docker run requires an image id!\n%s", Usage) return } run(evt, argv) } } // TODO: the idea is to be able to run an interactive container that may be more // than a single command, e.g. an old-school question/answer script that asks a // few questions then does some work. This will probably require a timeout // and some way to either signal which container you're messaging or spawn a // DM room for the purpose and perhaps send the output back to the originating // room. The DM approach is likely least complex, even across brokers. func run(evt hal.Evt, argv []string) { // danger! insecure! Demo code ;) cmd := exec.Command("/usr/bin/docker", argv[1:]...) out, err := cmd.Output() if err != nil { evt.Replyf("Encountered an error while running 'docker run %s': %s", argv[2], err) } evt.Reply(string(out)) } func images(evt hal.Evt) { cmd := exec.Command("/usr/bin/docker", "images") out, err := cmd.Output() if err != nil { evt.Replyf("Encountered an error while running 'docker images': %s", err) } evt.Reply(string(out)) } ================================================ FILE: plugins/google_calendar/google.go ================================================ package google_calendar /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "time" "github.com/netflix/hal-9001/hal" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/calendar/v3" ) const oauthJsonKey = `google-calendar-oauth-client-json` // a simplified calendar event returned by getEvents type CalEvent struct { Start time.Time End time.Time AllDay bool Name string Description string } type GoogleError struct { Parent error } func (e GoogleError) Error() string { return fmt.Sprintf("Failed while communicating with Google Calender: %s", e.Parent.Error()) } type PrefMissingError struct{} func (e PrefMissingError) Error() string { return `the calendar-id pref must be set for the room. Try: !prefs set --room * --plugin google_calendar --key calendar-id --value ` } type SecretMissingError struct{} func (e SecretMissingError) Error() string { return "the google-calendar-oauth-client-json secret must be set. Contact the bot admin." } func getEvents(calendarId string, now time.Time) ([]CalEvent, error) { // TODO: figure out if it's feasible to have one secret per bot or // if it really needs to be per-calendar or room // TODO: this should probably be passed to this function rather than // making this file require hal secrets := hal.Secrets() jsonData := secrets.Get("google-calendar-oauth-client-json") if jsonData == "" { return nil, SecretMissingError{} } config, err := google.JWTConfigFromJSON([]byte(jsonData), calendar.CalendarReadonlyScope) if err != nil { return nil, GoogleError{err} } client := config.Client(oauth2.NoContext) cal, err := calendar.New(client) if err != nil { return nil, GoogleError{err} } min := now.Add(time.Hour * -1).Format(time.RFC3339) max := now.Add(time.Hour * 24).Format(time.RFC3339) events, err := cal.Events.List(calendarId). ShowDeleted(false). SingleEvents(true). TimeMin(min). TimeMax(max). Do() if err != nil { return nil, GoogleError{err} } out := make([]CalEvent, len(events.Items)) for i, event := range events.Items { var start, end time.Time var allday bool // try twice to parse the time fields: // all-day events have a date field and datetime is empty if event.Start.DateTime != "" { start, err = time.Parse(time.RFC3339, event.Start.DateTime) if err != nil { log.Printf("Failed to parse start time from calendar event: %s", err) } } else if event.Start.Date != "" { // the timezone seems to always be blank - not sure if it's a bug in // the API or expected behavior. Either way, the downstream code // evaluating the returned time will have to check for all-day // and do the right thing // leaving this here (and in the end block below) for now while I // investigate what's going on zone, err := time.LoadLocation(event.Start.TimeZone) if err != nil { log.Printf("Failed to parse start date TimeZone %q from calendar event: %s", event.Start.TimeZone, err) } start, err = time.ParseInLocation("2006-01-02", event.Start.Date, zone) if err != nil { log.Printf("Failed to parse start date from all-day calendar event: %s", err) continue } allday = true } else { log.Println("event start time/date are both empty!") continue } if event.End.DateTime != "" { end, err = time.Parse(time.RFC3339, event.End.DateTime) if err != nil { log.Printf("Failed to parse end time from calendar event: %s", err) } } else if event.End.Date != "" { zone, err := time.LoadLocation(event.End.TimeZone) if err != nil { log.Printf("Failed to parse end date TimeZone %q from calendar event: %s", event.End.TimeZone, err) } log.Debugf("endZone: %q", event.End.TimeZone) end, err = time.ParseInLocation("2006-01-02", event.End.Date, zone) if err != nil { log.Printf("Failed to parse end date from all-day calendar event: %s", err) } // the event actually ends at 00:00:00 the next day so add a day end = end.AddDate(0, 0, 1) allday = true } else { log.Println("event end time/date are both empty!") continue } out[i].Start = start out[i].End = end out[i].AllDay = allday out[i].Name = event.Summary out[i].Description = event.Description } return out, nil } ================================================ FILE: plugins/google_calendar/plugin.go ================================================ package google_calendar /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // TODO: announce start / end import ( "fmt" "strconv" "strings" "sync" "time" "github.com/netflix/hal-9001/hal" ) var log hal.Logger const Usage = `!gcal (silence|status|expire|reload) !gcal silence 4h !gcal reload Even when attached, this plugin will not do anything until it is fully configured for the room. At a mininum the calendar-id needs to be set. One or all of autoreply, announce-start, and announce-end should be set to true to make anything happen. Setting up: !prefs set --room --plugin google_calendar --key calendar-id --value autoreply: when set to true, the bot will reply with a message for any activity in the room during hours when an event exists on the calendar. If the event has a description set, that will be the text sent to the room. Otherwise a default message is generated. !prefs set --room --plugin google_calendar --key autoreply --value true announce-(start|end): the bot will automatically announce when an event is starting or ending. The event's description will be included if it is not empty. !prefs set --room --plugin google_calendar --key announce-start --value true !prefs set --room --plugin google_calendar --key announce-end --value true timezone: optional, tells the bot which timezone to report dates in !prefs set --room --plugin google_calendar --key timezone --value America/Los_Angeles ` const DefaultTz = "America/Los_Angeles" const DefaultMsg = "Calendar event: %q" type Config struct { RoomId string CalendarId string Timezone time.Location Autoreply bool AnnounceStart bool AnnounceEnd bool CalEvents []CalEvent EvtsSinceLast int mut sync.Mutex configTs time.Time calTs time.Time } var configCache map[string]*Config var topMut sync.Mutex var mentionWords = [...]string{"@here", "@all"} func init() { configCache = make(map[string]*Config) log.SetPrefix("plugins/google_calendar") } func Register() { p := hal.Plugin{ Name: "google_calendar", Func: handleEvt, Init: initData, } p.Register() } // initData primes the cache and starts the background goroutine func initData(inst *hal.Instance) { topMut.Lock() config := Config{RoomId: inst.RoomId} configCache[inst.RoomId] = &config topMut.Unlock() pf := hal.PeriodicFunc{ Name: "google_calendar-" + inst.RoomId, Interval: time.Minute * 10, Function: func() { updateCachedCalEvents(inst.RoomId) }, } pf.Register() go func() { time.Sleep(time.Second * 5) pf.Start() }() } // handleEvt handles events coming in from the chat system. It does not interact // directly with the calendar API and relies on the background goroutine to populate // the cache. func handleEvt(evt hal.Evt) { // don't process non-chat or messages with an empty body if !evt.IsChat || evt.Body == "" { return } if strings.HasPrefix(strings.TrimSpace(evt.Body), "!") { handleCommand(&evt) return } now := time.Now() // use the hal kv store to prevent spamming // the spam keys are written with a 1 hour TTL so there's no need to examine the time // except for debugging purposes userSpamKey := getSpamKey("user", evt.UserId) userTs, _ := hal.GetKV(userSpamKey) // users can !gcal silence to silence the messages for the whole room e.g. during an incident roomSpamKey := getSpamKey("room", evt.RoomId) roomTs, _ := hal.GetKV(roomSpamKey) // always reply to @here/@everyone, etc. var isBroadcast bool for _, mention := range mentionWords { if strings.Contains(evt.Body, mention) { isBroadcast = true break } } config := getCachedConfig(evt.RoomId, now) calEvents, err := config.getCachedCalEvents(now) if err != nil { nospamReplyf(&evt, "Error while getting calendar data: %s", err) return } log.Debugf("handleEvt checking message. Replied to user at: %q. Replied to room at: %q. %d events since last reply", userTs, roomTs, config.EvtsSinceLast) // count events since the last notification to the room if roomTs != "" { config.EvtsSinceLast++ // wait for at least 20 events before notifying again // TODO: should this be configurable? if config.EvtsSinceLast > 20 { // some events have passed and the message has likely been scrolled // off most screens so let it hit the room again roomTs = "" } } // the user/room has been notified in the last hour, nothing to do now if !isBroadcast && (userTs != "" || roomTs != "") { log.Printf("Not responding to message because a reply was sent already. user @ %q, room @ %q", userTs, roomTs) return } for _, e := range calEvents { log.Debugf("Autoreply: %t, Now: %q, Start: %q, End: %q", config.Autoreply, now.String(), e.Start.String(), e.End.String()) log.Debugf("e.Description: %q, e.Name: %q", e.Description, e.Name) if config.Autoreply && e.Start.Before(now) && e.End.After(now) { msg := e.Description if msg == "" { msg = fmt.Sprintf(DefaultMsg, e.Name) } evt.Reply(msg) expire := e.End.Sub(now) hal.SetKV(userSpamKey, now.Format(time.RFC3339), expire) // only notify each user once per calendar event hal.SetKV(roomSpamKey, now.Format(time.RFC3339), expire) // only notify the room again if it gets busy log.Debugf("will not notify room %q for 10 minutes or the user %q for 2 hours", roomSpamKey, userSpamKey) config.EvtsSinceLast = 0 break // only notify once even if there are overlapping entries } } } // nospamReplyf keeps track of error replies and only replies with the same message // once per hour. func nospamReplyf(evt *hal.Evt, msg string, a ...interface{}) { errSpamKey := getSpamKey("err", evt.RoomId) errStr, _ := hal.GetKV(errSpamKey) reply := fmt.Sprintf(msg, a...) if errStr == reply { log.Println(reply) return } evt.Reply(reply) hal.SetKV(errSpamKey, reply, time.Hour) } func handleCommand(evt *hal.Evt) { argv := evt.BodyAsArgv() if argv[0] != "!gcal" { return } if len(argv) < 2 { evt.Replyf(Usage) return } now := time.Now() config := getCachedConfig(evt.RoomId, now) switch argv[1] { case "status": evt.Replyf("Calendar cache is %.f minutes old. Config cache is %.f minutes old.", now.Sub(config.calTs).Minutes(), now.Sub(config.configTs).Minutes()) case "help": evt.Replyf(Usage) case "expire": config.expireCaches() evt.Replyf("config & calendar caches expired") case "reload": config.expireCaches() updateCachedCalEvents(evt.RoomId) evt.Replyf("reload complete") case "silence": if len(argv) == 3 { d, err := time.ParseDuration(argv[2]) if err != nil { evt.Replyf("Invalid silence duration %q: %s", argv[2], err) } else { key := getSpamKey("room", evt.RoomId) hal.SetKV(key, "-", d) evt.Replyf("Calendar notifications silenced for %s.", d.String()) } } else { evt.Reply("Invalid command. A duration is requried, e.g. !gcal silence 4h") } } } func getSpamKey(scope, id string) string { return "gcal-" + scope + "-spam-" + id } func updateCachedCalEvents(roomId string) { log.Debugf("START: updateCachedCalEvents(%q)", roomId) now := time.Now() topMut.Lock() c := configCache[roomId] topMut.Unlock() c.LoadFromPrefs() // update the config from prefs evts, err := getEvents(c.CalendarId, now) if err != nil { log.Printf("FAILED: updateCachedCalEvents(%q): %s", roomId, err) return } c.mut.Lock() c.calTs = now c.CalEvents = evts c.mut.Unlock() log.Debugf("DONE: updateCachedCalEvents(%q)", roomId) } func getCachedConfig(roomId string, now time.Time) *Config { topMut.Lock() c := configCache[roomId] topMut.Unlock() age := now.Sub(c.configTs) if age.Minutes() > 10 { c.LoadFromPrefs() } return c } // getCachedEvents fetches the calendar data from the Google Calendar API, func (c *Config) getCachedCalEvents(now time.Time) ([]CalEvent, error) { c.mut.Lock() calAge := now.Sub(c.calTs) c.mut.Unlock() if calAge.Hours() > 1.1 { log.Debugf("%q's calendar cache appears to be expired after %f hours", c.RoomId, calAge.Hours()) evts, err := getEvents(c.CalendarId, now) if err != nil { log.Printf("Error encountered while fetching calendar events: %s", err) return nil, err } else { c.mut.Lock() c.calTs = now c.CalEvents = evts c.mut.Unlock() } } return c.CalEvents, nil } func (c *Config) LoadFromPrefs() error { c.mut.Lock() defer c.mut.Unlock() cidpref := hal.GetPref("", "", c.RoomId, "google_calendar", "calendar-id", "") if cidpref.Success { c.CalendarId = cidpref.Value } else { return fmt.Errorf("Failed to load calendar-id preference for room %q: %s", c.RoomId, cidpref.Error) } c.Autoreply = c.loadBoolPref("autoreply") c.AnnounceStart = c.loadBoolPref("announce-start") c.AnnounceEnd = c.loadBoolPref("announce-end") tzpref := hal.GetPref("", "", c.RoomId, "google_calendar", "timezone", DefaultTz) tz, err := time.LoadLocation(tzpref.Value) if err != nil { return fmt.Errorf("Could not load timezone info for '%s': %s\n", tzpref.Value, err) } c.Timezone = *tz c.configTs = time.Now() return nil } func (c *Config) expireCaches() { c.calTs = time.Time{} c.configTs = time.Time{} } func (c *Config) loadBoolPref(key string) bool { pref := hal.GetPref("", "", c.RoomId, "google_calendar", key, "false") val, err := strconv.ParseBool(pref.Value) if err != nil { log.Printf("unable to parse boolean pref value: %s", err) return false } return val } ================================================ FILE: plugins/guys/plugin.go ================================================ package guys /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "github.com/netflix/hal-9001/hal" ) func Register() { guys := hal.Plugin{ Name: "guys", Func: guys, Regex: "(?i:guys)", } guys.Register() } // guys counts how many times you've used "guys" in a chat message and // lets you know via DM // !plugin attach guys // this gets it listening to the room but it won't notify you until a pref is set // !prefs set --user * --plugin guys --key enabled --value true // or // !prefs set --room * --plugin guys --key enabled --value true func guys(evt hal.Evt) { if !evt.IsChat { return } key := "guys-" + evt.UserId hal.IncrementCounter(key) // even if this plugin is attached to a room it won't notify without // an accompanying pref to say whether it's a specific user who cares // or the whole room userCares := hal.GetPref(evt.UserId, "", "", "guys", "enabled", "false") roomCares := hal.GetPref("", "", evt.RoomId, "guys", "enabled", "false") if userCares.Value == "false" && roomCares.Value == "false" { return } count, _ := hal.GetCounter(key) msg := fmt.Sprintf("Yo. You have now used \"guys\" %d times.", count) // only let the user know - no need to publicly shame or clutter the room evt.ReplyDM(msg) } ================================================ FILE: plugins/inspect/plugin.go ================================================ package inspect /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import "github.com/netflix/hal-9001/hal" var log hal.Logger func Register() { getid := hal.Plugin{ Name: "getid", Func: getid, Command: "getid", } getid.Register() leave := hal.Plugin{ Name: "leave", Func: leave, Regex: "^[[:space:]]*!leave", } leave.Register() } // getid resolves user & room names to ids using the broker's RoomNameToId // and UserNameToId methods (along with the LooksLike* variants). func getid(evt hal.Evt) { args := evt.BodyAsArgv() if len(args) != 2 { evt.Replyf("%s requires exactly 2 arguments. Only %d were provided. e.g. !getid atobey", args[0], len(args)) return } maybeRoomId := evt.Broker.RoomNameToId(args[1]) maybeUserId := evt.Broker.UserNameToId(args[1]) if evt.Broker.LooksLikeRoomId(maybeRoomId) { evt.Replyf("Room: %q => %q", args[1], maybeRoomId) } else if evt.Broker.LooksLikeUserId(maybeUserId) { evt.Replyf("User: %q => %q", args[1], maybeUserId) } else { evt.Replyf("Could not resolve %q as a user or room.", args[1]) } } func leave(evt hal.Evt) { log.Printf("Leaving room %q as requested by %s.", evt.RoomId, evt.User) evt.Replyf("Leaving room %q as requested by %s.", evt.RoomId, evt.User) err := evt.Broker.Leave(evt.RoomId) if err != nil { evt.Replyf("Error leaving room %q: %s", evt.RoomId, err) } } ================================================ FILE: plugins/mark/plugin.go ================================================ package mark /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "fmt" "net/http" "strings" "time" "github.com/netflix/hal-9001/hal" ) var log hal.Logger type Mark struct { Timestamp time.Time `json:"timestamp"` User string `json:"user"` Room string `json:"room"` Broker string `json:"broker"` Note string `json:"note"` } const MarkTable = ` CREATE TABLE IF NOT EXISTS marks ( ts TIMESTAMP, user VARCHAR(191), room VARCHAR(191), broker VARCHAR(191), note TEXT, PRIMARY KEY (ts,user,room,broker) )` func Register() { mark := hal.Plugin{ Name: "mark", Command: "mark", Func: mark, } mark.Register() hal.SqlInit(MarkTable) http.HandleFunc("/v1/marks", httpGetMarks) } func mark(evt hal.Evt) { args := evt.BodyAsArgv() // check for !marks list or !marks --list and do that instead if len(args) > 1 && (args[1] == "list" || args[1] == "--list") { listMarks(evt) return } // strip the leading "!mark " note := strings.TrimSpace(evt.Body) note = strings.TrimPrefix(note, "!mark") note = strings.TrimSpace(note) sql := `INSERT INTO marks (ts, user, room, broker, note) VALUES (?, ?, ?, ?, ?)` _, err := hal.SqlDB().Exec(sql, evt.Time, evt.UserId, evt.RoomId, evt.BrokerName(), note) if err != nil { log.Printf("Could not insert mark into database: %s\n", err) } log.Printf("Mark added at %s with note %q", evt.Time, note) evt.Replyf("Mark added at %s with note %q", evt.Time, note) } func listMarks(evt hal.Evt) { marks, err := FetchMarks(evt.RoomId, 50) if err != nil { evt.Replyf("could not fetch marks: '%s'", err) return } data := make([][]string, len(marks)) for i, mark := range marks { user := evt.Broker.UserIdToName(mark.User) data[i] = []string{mark.Timestamp.String(), user, mark.Note} } evt.ReplyTable([]string{"Time", "User", "Note"}, data) } func httpGetMarks(w http.ResponseWriter, r *http.Request) { marks, err := FetchMarks("", 50) if err != nil { http.Error(w, fmt.Sprintf("could not fetch marks: '%s'", err), 500) return } js, err := json.Marshal(marks) if err != nil { http.Error(w, fmt.Sprintf("could not marshal marks to json: '%s'", err), 500) return } w.Write(js) } func FetchMarks(room string, limit int) ([]Mark, error) { db := hal.SqlDB() sql := `SELECT UNIX_TIMESTAMP(ts) AS ts, user, room, broker, note FROM marks WHERE ts < ? AND ts > ?` // temporary - these will be parameters eventually // will probably also add query gen for filtering by user/room/broker now := time.Now() yesterday := now.Add(-time.Hour * 24) params := make([]interface{}, 2) params[0] = &now params[1] = &yesterday if room != "" { sql = sql + " AND room=?" params = append(params, &room) } sql = sql + " ORDER BY ts DESC" rows, err := db.Query(sql, params...) if err != nil { log.Printf("marks query failed: %s\n", err) return nil, err } defer rows.Close() marks := make([]Mark, 0) for rows.Next() { mark := Mark{} var ts int64 err = rows.Scan(&ts, &mark.User, &mark.Room, &mark.Broker, &mark.Note) if err != nil { log.Printf("Row iteration failed: %s\n", err) return nil, err } mark.Timestamp = time.Unix(ts, 0) marks = append(marks, mark) } return marks, nil } ================================================ FILE: plugins/pagerduty/helpers.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bytes" "fmt" "net/http" "net/url" "strings" "github.com/netflix/hal-9001/hal" ) var log hal.Logger func init() { log.SetPrefix("plugins/pagerduty") } // AuthenticatedGet authenticates with the provided token and GETs the url. func authenticatedGet(geturl, token string) (*http.Response, error) { tokenHdr := fmt.Sprintf("Token token=%s", token) req, err := http.NewRequest("GET", geturl, nil) if err != nil { return nil, err } req.Header.Add("Accept", "application/vnd.pagerduty+json;version=2") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", tokenHdr) client := &http.Client{} r, err := client.Do(req) return r, err } // AuthenticatedPost authenticates with the provided token and posts the // provided body. func authenticatedPost(token, postUrl string, body []byte) (*http.Response, error) { tokenHdr := fmt.Sprintf("Token token=%s", token) buf := bytes.NewBuffer(body) req, err := http.NewRequest("POST", postUrl, buf) if err != nil { return nil, err } req.Header.Add("Accept", "application/vnd.pagerduty+json;version=2") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", tokenHdr) client := &http.Client{} return client.Do(req) } func pagedUrl(resource string, offset, limit int, params map[string][]string) string { out := fmt.Sprintf("https://api.pagerduty.com%s", resource) query := make([]string, 0) if limit != 0 { query = append(query, fmt.Sprintf("limit=%d", limit)) } if offset != 0 { query = append(query, fmt.Sprintf("offset=%d", offset)) } if params != nil { for k, vlist := range params { for _, vv := range vlist { query = append(query, fmt.Sprintf("%s=%s", k, url.QueryEscape(vv))) } } } if len(query) > 0 { return fmt.Sprintf("%s?%s", out, strings.Join(query, "&")) } return out } ================================================ FILE: plugins/pagerduty/oncall_plugin.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "bytes" "fmt" "sort" "strings" "sync" "time" "github.com/netflix/hal-9001/hal" ) const OncallUsage = `!oncall Find out who is oncall. If only one argument is provided, it must match a known alias for a Pagerduty service. Otherwise, it is expected to be a subcommand. !oncall core ` const DefaultTopicInterval = "10m" var onePerToken map[string]sync.Mutex var mapLock sync.Mutex func init() { onePerToken = make(map[string]sync.Mutex) } // TODO: add the service key to the output such that someone trying to contact a team // can page them from within Slack without having to set up a page alias or go out // to a web page. !page should be able to take a service key so the output can include something // like: "To page use the command: !page " func oncall(msg hal.Evt) { parts := msg.BodyAsArgv() if len(parts) == 1 { msg.Reply(OncallUsage) return } // make sure the pagerduty token is setup in hal.Secrets token, err := getSecrets() if err != nil || token == "" { msg.Replyf("pagerduty: %s is not set up in hal.Secrets. Cannot continue.", PagerdutyTokenKey) return } if parts[1] == "cache-now" { msg.Reply("Updating Pagerduty policy cache now.") getOncallCache(token, true) msg.Reply("Pagerduty policy cache update complete.") return } else if parts[1] == "cache-status" { age := int(hal.Cache().Age(CacheKey).Seconds()) next := time.Time{} status := "broken" pf := hal.GetPeriodicFunc("pagerduty-oncall-cache") if pf != nil { next = pf.Last().Add(pf.Interval) status = pf.Status() } msg.Replyf("The cache is %d seconds old. Auto-update is %s and its next update is at %s.", age, status, next.Format(time.UnixDate)) return } else if len(parts) > 2 { // flatten split words back into position 1, parts isn't used after the ToLower parts[1] = strings.Join(parts[1:], " ") } // TODO: look at the aliases set up for !page and try for an exact match // before doing fuzzy search -- move fuzzy search to a "search" subcommand // so it's clear that it is not precise want := strings.ToLower(parts[1]) // see if there's an exact match on an alias, e.g. "!oncall core" -> alias.core /* aliasPref := msg.AsPref().SetUser("").FindKey(aliasKey(want)).One() if aliasPref.Success { svc, err := GetServiceByKey(token, aliasPref.Value) if err == nil { } // all through to search ... } */ // search over all policies looking for matching policy name, escalation // rule name, or service name matches := make(map[string][]Oncall) oncalls := getOncallCache(token, false) var exactMatchFound bool addMatch := func(matches map[string][]Oncall, oncall Oncall) { key := oncall.EscalationPolicy.Summary if _, exists := matches[key]; exists { matches[key] = append(matches[key], oncall) } else { matches[key] = []Oncall{oncall} } } for _, oncall := range oncalls { schedSummary := clean(oncall.Schedule.Summary) if schedSummary == want { addMatch(matches, oncall) exactMatchFound = true continue } else if !exactMatchFound && strings.Contains(schedSummary, want) { addMatch(matches, oncall) continue } epDesc := clean(oncall.EscalationPolicy.Description) if epDesc == want { addMatch(matches, oncall) exactMatchFound = true continue } else if !exactMatchFound && strings.Contains(epDesc, want) { addMatch(matches, oncall) continue } epSummary := clean(oncall.EscalationPolicy.Summary) if epSummary == want { addMatch(matches, oncall) exactMatchFound = true continue } else if !exactMatchFound && strings.Contains(epSummary, want) { addMatch(matches, oncall) continue } } // check team names if there were no matches // TODO: cache some of these results and always check team names teams, err := GetTeams(token, nil) if err != nil { log.Printf("REST call to Pagerduty /teams failed: %s", err) } else { for _, team := range teams { ltname := clean(team.Name) ltdesc := clean(team.Description) if strings.Contains(ltname, want) || strings.Contains(ltdesc, want) { oncalls := getTeamOncalls(token, team) for _, oncall := range oncalls { addMatch(matches, oncall) } } } } reply := formatOncallReply(want, exactMatchFound, matches) msg.Reply(reply) } // getTeamOncalls fetches escalation policies for the team then the oncalls for those // policies and returns a list. func getTeamOncalls(token string, team Team) []Oncall { mut := getMutex(token) mut.Lock() defer mut.Unlock() out := make([]Oncall, 0) params := map[string][]string{"team_ids[]": []string{team.Id}} policies, err := GetEscalationPolicies(token, params) if err != nil { log.Printf("Error while fetching escalation policies for team id %q: %s", team.Id, err) return out } policy_ids := make([]string, 0) for _, policy := range policies { policy_ids = append(policy_ids, policy.Id) } params = map[string][]string{ "include[]": []string{"users"}, "escalation_policy_ids[]": policy_ids, } oncalls, err := GetOncalls(token, params) if err != nil { log.Printf("Error while fetching oncalls for team id %q's policies: %s", team.Id, err) } else { return oncalls } return out } func getOncallCache(token string, forceUpdate bool) []Oncall { mut := getMutex(token) mut.Lock() defer mut.Unlock() oncalls := []Oncall{} if !forceUpdate { // see if there's a copy cached if hal.Cache().Exists(CacheKey) { ttl, err := hal.Cache().Get(CacheKey, &oncalls) if err != nil { log.Printf("Error retreiving oncalls from the Hal TTL cache: %s", err) oncalls = []Oncall{} } else if ttl == 0 { oncalls = []Oncall{} } } // the cache exists and is still valid, return it now if len(oncalls) > 0 { return oncalls } } // get all of the defined policies params := map[string][]string{"include[]": []string{"users"}} oncalls, err := GetOncalls(token, params) if err != nil { log.Printf("Returning empty list. REST call to Pagerduty failed: %s", err) return []Oncall{} } // set the cache to expire 1 minute later than the polling interval cacheExpire := getCacheFreq() + time.Minute hal.Cache().Set(CacheKey, &oncalls, cacheExpire) return oncalls } func getCacheFreq() time.Duration { cacheFreq := hal.GetPref("", "", "", "pagerduty", "cache-update-frequency", DefaultCacheInterval) cd, err := time.ParseDuration(cacheFreq.Value) if err != nil { log.Panicf("BUG: could not parse cache update frequency preference: %q", cacheFreq.Value) } return cd } func getTopicFreq(roomId string) time.Duration { topicFreq := hal.GetPref("", "", roomId, "pagerduty", "topic-update-frequency", DefaultTopicInterval) td, err := time.ParseDuration(topicFreq.Value) if err != nil { log.Panicf("BUG: could not parse topic update frequency preference: %q", topicFreq.Value) } return td } func oncallInit(i *hal.Instance) { cacheFreq := getCacheFreq() topicFreq := getTopicFreq(i.RoomId) token, err := getSecrets() if err != nil || token == "" { return // getSecrets will log the error } pf := hal.PeriodicFunc{ Name: "pagerduty-oncall-cache", Interval: cacheFreq, Function: func() { pollOncalls(token) }, } pf.Register() go pf.Start() tpf := hal.PeriodicFunc{ Name: topicFuncName(i.RoomId), Interval: topicFreq, Function: func() { topicUpdater(token, i.RoomId, i.Broker.Name()) }, } tpf.Register() go tpf.Start() // TODO: add a command to stop, etc. } func pollOncalls(token string) { getOncallCache(token, true) } // topicUpdater runs periodically to update the topic in the room // it's configured in. // To fully enable it, you need the oncall schedule id from the pagerduty API. // !prefs set --room * --broker slack --plugin pagerduty --key topic-updater-schedule-id --value // !prefs set --room * --broker slack --plugin pagerduty --key topic-prefix --value // !prefs set --room * --broker slack --plugin pagerduty --key topic-suffix --value // TODO: see if there's a way to also resolve integration keys instead of using the schedule id func topicUpdater(token, roomId, brokerName string) { mut := getMutex(token) mut.Lock() defer mut.Unlock() log.Debugf("ENTER topicUpdater(token, %q, %q)", roomId, brokerName) pref := hal.GetPref("", brokerName, roomId, "pagerduty", "topic-updater-schedule-id", "-") // probably not configured, nothing to see here... if !pref.Success || pref.Value == "-" { log.Debugf("The pref ''/%q/%q/pagerduty/topic-updater-schedule-id does not seem to be set. Returning without taking action.", brokerName, roomId) return } params := map[string][]string{ "include[]": []string{"users", "contact_methods"}, "schedule_ids[]": []string{pref.Value}, } oncalls, err := GetOncalls(token, params) if err != nil { log.Printf("Failed to fetch oncalls for schedule id %q: %s", pref.Value, err) return } log.Debugf("Got %d users for schedule id %q", len(oncalls), pref.Value) // there may be more than one entry but if they're both on the same // schedule it should be the same primary oncall so ignore all but the first if len(oncalls) == 0 { log.Printf("no oncall results for id %q", pref.Value) return } // TODO: yet another place some kind of templating support would be handy prefix := hal.GetPref("", brokerName, roomId, "pagerduty", "topic-prefix", "") suffix := hal.GetPref("", brokerName, roomId, "pagerduty", "topic-suffix", "") // e.g. prefix = "", summary = "Al Tobey", suffix = " [team-dl@company.com] !pageus" topic := prefix.Value + oncalls[0].User.Summary + suffix.Value broker := hal.Router().GetBroker(brokerName) oldTopic, err := broker.GetTopic(roomId) if err != nil { log.Printf("Could not fetch current topic for room %q: %s", roomId, err) return } // only do the update if the topic has changed if topic != oldTopic { broker.SetTopic(roomId, topic) } } func topicFuncName(roomId string) string { return fmt.Sprintf("pagerduty-topic-updater-%s", roomId) } // OncallsByLevel provides sorting by oncall level for []Oncall. type OncallsByLevel []Oncall func (a OncallsByLevel) Len() int { return len(a) } func (a OncallsByLevel) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a OncallsByLevel) Less(i, j int) bool { // sort "always on call" users to the end of the list if a[j].Schedule.Summary == "" { return true } return a[i].EscalationLevel < a[j].EscalationLevel } func formatOncallReply(wanted string, exactMatchFound bool, matches map[string][]Oncall) string { buf := bytes.NewBuffer([]byte{}) if exactMatchFound { for _, oncalls := range matches { fmt.Fprintf(buf, "exact match found for %q\n", oncalls[0].EscalationPolicy.Summary) } } else { fmt.Fprintf(buf, "%d records matched for query: %q\n", len(matches), wanted) } keys := make([]string, 0) for key, _ := range matches { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { oncalls := matches[key] sort.Sort(OncallsByLevel(oncalls)) for _, oncall := range oncalls { indent := strings.Repeat(" ", oncall.EscalationLevel) sched := oncall.Schedule.Summary if sched == "" { sched = "always on call" } if exactMatchFound { fmt.Fprintf(buf, "%s%s - %s\n", indent, oncall.User.Summary, sched) } else { fmt.Fprintf(buf, "%s%s - %s - %s\n", indent, oncall.EscalationPolicy.Summary, oncall.User.Summary, sched) } } } return buf.String() } func getMutex(token string) sync.Mutex { mapLock.Lock() defer mapLock.Unlock() if _, exists := onePerToken[token]; !exists { var mut sync.Mutex onePerToken[token] = mut } return onePerToken[token] } func clean(in string) string { lower := strings.ToLower(in) clean := strings.Trim(lower, `()[]{}<>~!@#$%^&*+/="',.?|`) return clean } ================================================ FILE: plugins/pagerduty/page_plugin.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "strings" "github.com/netflix/hal-9001/hal" ) const PageUsage = `!page [optional message] Send an alert via Pagerduty with an optional custom message. Aliases that have a comma-separated list of service keys will result in one page going to each service key when the alias is paged. !page core !page core !pagecore HELP ME YOU ARE MY ONLY HOPE !page add !page add ,,,... !page rm !page list ` const PageDefaultMessage = `your presence is requested in the chat room` func page(msg hal.Evt) { parts := msg.BodyAsArgv() // detect concatenated command + team name & split them // e.g. "!pagecore" -> {"!page", "core"} if strings.HasPrefix(parts[0], "!page") && len(parts[0]) > 5 { team := strings.TrimPrefix(parts[0], "!page") parts = append([]string{"!page", team}, parts[1:]...) } // should be 2 parts now, "!page" and the target team at a minimum if parts[0] != "!page" || len(parts) < 2 { msg.Reply(PageUsage) return } switch parts[1] { case "h", "help": msg.Reply(PageUsage) case "add": addAlias(msg, parts[2:]) case "rm": rmAlias(msg, parts[2:]) case "list": listAlias(msg) default: pageAlias(msg, parts[1:]) } } func pageAlias(evt hal.Evt, parts []string) { pageMessage := PageDefaultMessage msgPref := evt.AsPref().FindKey("default-message").Room(evt.RoomId).One() // Caller slices off the !page. parts[0] should be the alias. // Anything after is a custom message. if len(parts) > 1 { pageMessage = strings.Join(parts[1:], " ") } else if msgPref.Success { pageMessage = msgPref.Value } // map alias name to PD token via prefs key := aliasKey(parts[0]) // make sure to filter on at least room id since FindKey might find duplicate // aliases from other rooms pref := evt.AsPref().FindKey(key).Room(evt.RoomId).One() // make sure the query succeeded if !pref.Success { if pref.Error != nil { evt.Replyf("Unable to access preferences: %#q", pref.Error) } else { evt.Replyf("Alias %q is not configured. Try !page add %s ", parts[0], parts[0]) } return } // if qpref.Get returned the default, the alias was not found if pref.Value == "" { evt.Replyf("Alias %q is not configured. Try !page add %s ", parts[0], parts[0]) return } // make sure the hal secrets are set up token, err := getSecrets() if err != nil { evt.Error(err) return } // the value can be a list of tokens, separated by commas for _, svckey := range strings.Split(pref.Value, ",") { // Pagerduty has confirmed that both V1 and V2 keys are supported on the V2 endpoint. pde2 := NewV2Event(svckey) pde2.Action = "trigger" pde2.Payload.Summary = pageMessage // required pde2.Payload.Source = evt.User // required pde2.Payload.Severity = "critical" // required pde2.Payload.Component = evt.BrokerName() // optional pde2.Payload.Group = evt.Room // optional pde2.Payload.Class = "!page" // optional resp, err := pde2.Send(token) if err != nil { log.Printf("Pagerduty V2 API failed: %s", err) evt.Replyf("Pagerduty V2 API failed! Your alert has NOT been delivered. Error: %s", err) return } // 200 means the alert has been sent. 202 is returned when the event is queued for delivery. if resp.StatusCode >= 200 && resp.StatusCode < 300 { log.Printf("Pagerduty V2 response message for %q -> %s(%s): %s\n", pageMessage, parts[0], svckey, resp.Message) evt.Replyf("Message sent to %s using integration key %s via Pagerduty V2 API.", parts[0], svckey) } } } func addAlias(msg hal.Evt, parts []string) { if len(parts) < 2 { msg.Replyf("!page add requires 2 arguments, e.g. !page add sysadmins XXXXXXX") return } else if len(parts) > 2 { keys := strings.Replace(strings.Join(parts[1:], ","), ",,", ",", len(parts)-2) parts = []string{parts[0], keys} } pref := msg.AsPref() pref.User = "" // filled in by AsPref and unwanted pref.Key = aliasKey(parts[0]) pref.Value = parts[1] err := pref.Set() if err != nil { msg.Replyf("Write failed: %s", err) } else { msg.Replyf("Added alias: %q -> %q", parts[0], parts[1]) } } func rmAlias(msg hal.Evt, parts []string) { if len(parts) != 1 { msg.Replyf("!page rm requires 1 argument, e.g. !page rm sysadmins") return } pref := msg.AsPref() pref.User = "" // filled in by AsPref and unwanted pref.Key = aliasKey(parts[0]) pref.Delete() msg.Replyf("Removed alias %q", parts[0]) } func listAlias(msg hal.Evt) { pref := msg.AsPref() pref.User = "" // filled in by AsPref and unwanted prefs := pref.GetPrefs() data := prefs.Table() msg.ReplyTable(data[0], data[1:]) } func aliasKey(alias string) string { return fmt.Sprintf("alias.%s", alias) } ================================================ FILE: plugins/pagerduty/pd_events_v1.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "errors" "fmt" "io/ioutil" ) // https://developer.pagerduty.com/documentation/integration/events/trigger const V1EventEndpoint = `https://events.pagerduty.com/generic/2010-04-15/create_event.json` // Context is an interface for the contexts field in V1 PD events. type Context interface { GetType() string } type ContextLink struct { Type string `json:"type"` Href string `json:"href"` Text string `json:"text,omitempty"` } type ContextImage struct { Type string `json:"type"` Src string `json:"src"` Href string `json:"href,omitempty"` Alt string `json:"alt,omitempty"` } type Event struct { ServiceKey string `json:"service_key"` EventType string `json:"event_type"` Description string `json:"description"` IncidentKey string `json:"incident_key,omitempty"` Details map[string]interface{} `json:"details,omitempty"` // arbitrary json Client string `json:"client,omitempty"` ClientUrl string `json:"client_url,omitempty"` Contexts []Context `json:"contexts,omitempty"` } type Error struct { Message string `json:"message"` Code int `json:"code"` Errors []string `json:"errors"` } type ErrorResponse struct { Error Error `json:"error"` } type Response struct { Status string `json:"status"` Message string `json:"message"` IncidentKey string `json:"incident_key,omitempty"` Errors []string `json:"errors,omitempty"` StatusCode int `json:""` } // NewEvent returns an initialized Event structure. You probably don't // want to use this and instead use NewTrigger/NewAck/NewResolve. func NewEvent(serviceKey, eventType, description string) *Event { return &Event{ ServiceKey: serviceKey, EventType: eventType, Description: description, Details: make(map[string]interface{}), Contexts: make([]Context, 0), } } func NewTrigger(serviceKey, description string) *Event { return NewEvent(serviceKey, "trigger", description) } func NewAck(serviceKey, description string) *Event { return NewEvent(serviceKey, "acknowledge", description) } func NewResolve(serviceKey, description string) *Event { return NewEvent(serviceKey, "resolve", description) } func NewResponse(status, message, incidentKey string) *Response { out := Response{ Status: status, Message: message, IncidentKey: incidentKey, Errors: make([]string, 0), } return &out } // Send posts the event to Pagerduty using the provided token. func (e *Event) Send(token string) (*Response, error) { err := e.checkRequired() if err != nil { return e.respond("error", err.Error()), err } js, err := json.Marshal(e) if err != nil { log.Printf("json.Marshal failed: %s\n", err) return e.respond("error", err.Error()), err } resp, err := authenticatedPost(token, V1EventEndpoint, js) if err != nil { return e.respond("error", err.Error()), err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode == 200 { out := Response{} err = json.Unmarshal(body, &out) if err != nil { log.Printf("json.Unmarshal failed: %s\n", err) return nil, err } out.StatusCode = resp.StatusCode return &out, nil } else { msg := fmt.Sprintf("Server returned %d: %q", resp, string(body)) return e.respond("error", msg), errors.New(msg) } } func (e *Event) respond(status, message string) *Response { return NewResponse(status, message, e.IncidentKey) } func (e *Event) checkRequired() error { et := e.EventType if len(et) == 0 { return errors.New("EventType is a required field.") } if et != "trigger" && et != "acknowledge" && et != "resolve" { msg := fmt.Sprintf("EventType must be one of 'trigger', 'acknowledge', or 'resolve'. Got: %q", et) return errors.New(msg) } if len(e.ServiceKey) == 0 { return errors.New("ServiceKey is a required field.") } if len(e.Description) == 0 { return errors.New("Description is a required field.") } return nil } func (c *ContextLink) GetType() string { return "link" } func (c *ContextImage) GetType() string { return "image" } ================================================ FILE: plugins/pagerduty/pd_events_v2.go ================================================ package pagerduty /* * Copyright 2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "errors" "fmt" "io/ioutil" ) // https://v2.developer.pagerduty.com/docs/events-api-v2 const V2EventEndpoint = `https://events.pagerduty.com/v2/enqueue` // data structures for the PagerDuty Common Event Format // Timestamp type EventPayload struct { Summary string `json:"summary"` // high-level text Severity string `json:"severity"` // enum: info, warning, error, critical Source string `json:"source,omitempty"` // e.g. hostname, IP, ARN Component string `json:"component,omitempty"` // e.g. "mysql", "keepalive" Group string `json:"group,omitempty"` // e.g. "www", "prod-data" Class string `json:"class,omitempty"` // e.g. "High CPU", "Latency" Custom map[string]string `json:"custom_details"` } type EventImage struct { Src string `json:"src"` Href string `json:"href"` Alt string `json:"alt"` } type EventBody struct { RoutingKey string `json:"routing_key"` Action string `json:"event_action"` // e.g. "trigger" DedupKey string `json:"dedup_key,omitempty"` // arbitrary key for server-side dedup Payload EventPayload `json:"payload"` Images []EventImage `json:"images"` Client string `json:"client"` // e.g. "Scorebot/#core" ClientUrl string `json:"client_url"` // e.g. "https://scorebot.prod.netflix.net" } type EventResult struct { Status string `json:"status"` // e.g. "success" Message string `json:"message"` // e.g. "Event processed" DedupKey string `json:"dedup_key"` // a uuid-ish key StatusCode int `json:"-"` } func NewV2Event(routingKey string) *EventBody { details := make(map[string]string) out := EventBody{ RoutingKey: routingKey, Action: "trigger", Payload: EventPayload{ // provide defaults for required fields Summary: "Something happened! This is the default summary.", Source: "unspecified", Severity: "error", Custom: details, }, Images: []EventImage{}, } return &out } func (eb *EventBody) Send(token string) (EventResult, error) { out := EventResult{Status: "failed"} err := eb.checkFields() if err != nil { return out, err } js, err := json.Marshal(eb) if err != nil { msg := fmt.Sprintf("json.Marshal failed: %s", err) out.Message = msg log.Println(msg) return out, err } resp, err := authenticatedPost(token, V2EventEndpoint, js) if err != nil { msg := fmt.Sprintf("POST failed: %s", err) out.Message = msg log.Println(msg) return out, err } defer resp.Body.Close() out.StatusCode = resp.StatusCode body, err := ioutil.ReadAll(resp.Body) if err != nil { return out, err } // 200 means the event has been received and is on its way to a device. // 202 means they received the event and will send asynchronously. // Return success for all 2xx results. if resp.StatusCode >= 200 && resp.StatusCode < 300 { err = json.Unmarshal(body, &out) if err != nil { msg := fmt.Sprintf("json.Unmarshal failed: %s", err) out.Status = "failed" out.Message = msg log.Println(msg) return out, err } return out, nil } else { msg := fmt.Sprintf("Server returned %d: %q", resp, string(body)) out.Message = msg return out, errors.New(msg) } } func (eb *EventBody) checkFields() error { // TODO: check some fields return nil } ================================================ FILE: plugins/pagerduty/pd_oncall.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "fmt" "io/ioutil" ) // https://v2.developer.pagerduty.com/v2/page/api-reference#!/On-Calls/get_oncalls // TODO: figure out if query should be a typed struct or validated func GetOncalls(token string, query map[string][]string) ([]Oncall, error) { oncalls := make([]Oncall, 0) offset := 0 limit := 100 for { oncallsResp := OncallsResponse{} url := pagedUrl("/oncalls", offset, limit, query) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return oncalls, err } data, err := ioutil.ReadAll(resp.Body) err = json.Unmarshal(data, &oncallsResp) if err != nil { fmt.Printf("\n\n%s\n\n", data) log.Printf("json.Unmarshal of data from %q failed: %s", url, err) return oncalls, err } oncalls = append(oncalls, oncallsResp.Oncalls...) if oncallsResp.More { offset = offset + limit } else { break } } return oncalls, nil } ================================================ FILE: plugins/pagerduty/pd_policy.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "io/ioutil" ) // https://v2.developer.pagerduty.com/v2/page/api-reference#!/Escalation_Policies/get_escalation_policies func GetEscalationPolicies(token string, params map[string][]string) ([]EscalationPolicy, error) { policies := make([]EscalationPolicy, 0) offset := 0 limit := 100 for { epresp := EscalationPolicyResponse{} url := pagedUrl("/escalation_policies", offset, limit, params) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return policies, err } data, err := ioutil.ReadAll(resp.Body) err = json.Unmarshal(data, &epresp) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return policies, err } policies = append(policies, epresp.EscalationPolicies...) if epresp.More { offset = offset + limit } else { break } } return policies, nil } ================================================ FILE: plugins/pagerduty/pd_schedule.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "fmt" "io/ioutil" ) type scheduleOncallUsersResponse struct { Users []User `json:"users"` } // https://v2.developer.pagerduty.com/v2/page/api-reference#!/Schedules/get_schedules_id func GetScheduleOncalls(token, id string) ([]User, error) { out := scheduleOncallUsersResponse{} url := pagedUrl("/schedules/"+id+"/users", 0, 0, nil) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return []User{}, err } data, err := ioutil.ReadAll(resp.Body) err = json.Unmarshal(data, &out) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return []User{}, err } return out.Users, nil } func GetSchedules(token string, params map[string][]string) ([]Schedule, error) { schedules := make([]Schedule, 0) offset := 0 limit := 100 for { schedulesResp := SchedulesResponse{} url := pagedUrl("/schedules", offset, limit, params) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return schedules, err } data, err := ioutil.ReadAll(resp.Body) err = json.Unmarshal(data, &schedulesResp) if err != nil { fmt.Printf("\n\n%s\n\n", data) log.Printf("json.Unmarshal of data from %q failed: %s", url, err) return schedules, err } schedules = append(schedules, schedulesResp.Schedules...) if schedulesResp.More { offset = offset + limit } else { break } } return schedules, nil } ================================================ FILE: plugins/pagerduty/pd_service.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // API docs: https://developer.pagerduty.com/documentation/rest/escalation_policies/on_call import ( "encoding/json" "io/ioutil" ) func GetServices(token string, params map[string][]string) ([]Service, error) { services := make([]Service, 0) offset := 0 limit := 100 for { svcResp := ServicesResponse{} svcsUrl := pagedUrl("/services", offset, limit, params) resp, err := authenticatedGet(svcsUrl, token) if err != nil { log.Printf("GET %s failed: %s", svcsUrl, err) return services, err } data, err := ioutil.ReadAll(resp.Body) err = json.Unmarshal(data, &svcResp) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return []Service{}, err } services = append(services, svcResp.Services...) if svcResp.More { offset = offset + limit } else { break } } return services, nil } func GetService(token, id string) (Service, error) { out := Service{ IncidentCounts: IncidentCounts{}, } svcsUrl := pagedUrl("/services/"+id, 0, 0, nil) resp, err := authenticatedGet(svcsUrl, token) if err != nil { log.Printf("GET %s failed: %s", svcsUrl, err) return out, err } data, err := ioutil.ReadAll(resp.Body) err = json.Unmarshal(data, &out) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return out, err } return out, nil } ================================================ FILE: plugins/pagerduty/pd_team.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "io/ioutil" ) // https://v2.developer.pagerduty.com/v2/page/api-reference#!/Teams/get_teams func GetTeams(token string, params map[string][]string) ([]Team, error) { out := make([]Team, 0) offset := 0 limit := 100 for { url := pagedUrl("/teams", offset, limit, params) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return out, err } data, err := ioutil.ReadAll(resp.Body) oresp := TeamsResponse{} err = json.Unmarshal(data, &oresp) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return out, err } out = append(out, oresp.Teams...) if oresp.More { offset = offset + limit } else { break } } return out, nil } ================================================ FILE: plugins/pagerduty/pd_types.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "time" ) type ContactMethod struct { Id string `json:"id"` Type string `json:"type"` Label string `json:"label"` Address string `json:"address"` SendShortEmail bool `json:"send_short_email"` } type NotificationRule struct { Id string `json:"id"` Type string `json:"type"` StartDelayInMinutes int `json:"start_delay_in_minutes"` CreatedAt string `json:"created_at"` Urgency string `json:"urgency"` ContactMethod ContactMethod `json:"contact_method"` } type Team struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` Name string `json:"name"` Description string `json:"description"` } type TeamRef struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` } type TeamsResponse struct { Teams []Team `json:"teams"` Offset int `json:"offset"` Limit int `json:"limit"` More bool `json:"more"` Total int `json:"total,omitempty"` } type Schedule struct { Id string `json:"id"` Summary string `json:"summary"` Type string `json:"type"` Self string `json:"self"` HtmlUrl string `json:"html_url"` ScheduleLayers []ScheduleLayer `json:"schedule_layers"` Timezone string `json:"time_zone"` Name string `json:"name"` Description string `json:"description"` FinalSchedule SubSchedule `json:"final_schedule,omitempty"` OverridesSubSchedule SubSchedule `json:"overrides_subschedule,omitempty"` EscalationPolicies []EscalationPolicy `json:"escalation_policies"` Users []UserRef `json:"users"` } type ScheduleRef struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` } type SchedulesResponse struct { Schedules []Schedule `json:"schedules"` Offset int `json:"offset"` Limit int `json:"limit"` More bool `json:"more"` Total int `json:"total,omitempty"` } type ScheduleLayer struct { Id string `json:"id,omitempty"` Start *time.Time `json:"start"` End *time.Time `json:"end,omitempty"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` Users []UserRef `json:"users"` Restrictions []Restriction `json:"restrictions"` RotationVirtualStart string `json:"rotation_virtual_start"` RotationTurnLengthSeconds int `json:"rotation_turn_length_seconds"` Name string `json:"name"` RenderedScheduleEntries []ScheduleLayerEntry `json:"rendered_schedule_entries"` RenderedCoveragePercentage float64 `json:"rendered_coverage_percentage"` } type SubSchedule struct { Name string `json:"name"` RenderedScheduleEntries []ScheduleLayerEntry `json:"rendered_schedule_entries"` RenderedCoveragePercentage float64 `json:"rendered_coverage_percentage"` } type ScheduleLayerEntry struct { User UserRef `json:"user"` Start string `json:"start"` End string `json:"end"` } type Restriction struct { Type string `json:"type"` DurationSeconds int `json:"duration_seconds"` StartTimeOfDay string `json:"start_time_of_day"` } type EscalationPolicy struct { Id string `json:"id"` Summary string `json:"summary"` Type string `json:"type"` Self string `json:"self"` HtmlUrl string `json:"html_url"` Name string `json:"name"` Description string `json:"description"` NumLoops int `json:"num_loops"` RepeatEnabled bool `json:"repeat_enabled"` EscalationRules []EscalationRule `json:"escalation_rules"` ServiceRefs []ServiceRef `json:"services"` TeamRefs []TeamRef `json:"teams"` } type EscalationPolicyResponse struct { EscalationPolicies []EscalationPolicy `json:"escalation_policies"` Offset int `json:"offset"` Limit int `json:"limit"` More bool `json:"more"` Total int `json:"total,omitempty"` } type EscalationRule struct { Id string `json:"id"` EscalationDelayInMinutes int `json:"escalation_delay_in_minutes"` Targets []EscalationTarget `json:"targets"` } type EscalationPolicyRef struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` } type EscalationTarget struct { Id string `json:"id"` Type string `json:"type"` } type PolicyService struct { Id string `json:"id"` Name string `json:"name"` IntegrationEmail string `json:"integration_email"` HtmlUrl string `json:"html_url"` EscalationPolicyId string `json:"escalation_policy_id"` } type Integration struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` Name string `json:"name"` CreatedAt string `json:"created_at"` IntegrationKey string `json:"integration_key"` IntegrationEmail string `json:"integration_email"` // ignore service // ignore vendor // ignore config } type IncidentCounts struct { Triggered int `json:"triggered"` Acknowledged int `json:"acknowledged"` Resolved int `json:"resolved"` Total int `json:"total"` } // Service represents a Pagerduty service object from /api/v1/services/:id type Service struct { Id string `json:"id"` Type string `json:"type"` Name string `json:"name"` ServiceUrl string `json:"service_url"` ServiceKey string `json:"service_key"` AutoResolveTimeout int `json:"auto_resolve_timeout"` AcknowledgementTimeout int `json:"acknowledgement_timeout"` CreatedAt string `json:"created_at"` Status string `json:"status"` LastIncidentTimestamp string `json:"last_incident_timestamp"` EmailIncidentCreation string `json:"email_incident_creation"` IncidentCounts IncidentCounts `json:"incident_counts"` EmailFilterMode string `json:"email_filter_mode"` Description string `json:"description"` Integrations []Integration `json:"integrations"` EscalationPolicy EscalationPolicy `json:"escalation_policy"` Teams []Team `json:"teams"` } type ServiceRef struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` } type ServicesResponse struct { Services []Service `json:"services"` Limit int `json:"limit"` Offset int `json:"offset"` More bool `json:"more"` Total int `json:"total"` } type User struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` Name string `json:"name"` Email string `json:"email"` JobTitle string `json:"job_title"` Timezone string `json:"time_zone"` Color string `json:"color"` Role string `json:"role,omitempty"` AvatarUrl string `json:"avatar_url,omitempty"` Description string `json:"description,omitempty"` Billed bool `json:"billed,omitempty"` UserUrl string `json:"user_url,omitempty"` InvitationSent bool `json:"invitation_sent,omitempty"` MarketingOptOut bool `json:"marketing_opt_out,omitempty"` ContactMethods []ContactMethod `json:"contact_methods"` NotificationRules []NotificationRule `json:"notification_rules"` Teams []Team `json:"teams"` } type UserRef struct { Id string `json:"id"` Type string `json:"type"` Summary string `json:"summary"` Self string `json:"self"` HtmlUrl string `json:"html_url"` } type UsersResponse struct { Users []User `json:"users"` Offset int `json:"offset"` Limit int `json:"limit"` More bool `json:"more"` Total int `json:"total"` } type Oncall struct { EscalationPolicy EscalationPolicy `json:"escalation_policy"` User User `json:"user"` Schedule Schedule `json:"schedule"` EscalationLevel int `json:"escalation_level"` Start *time.Time `json:"start"` End *time.Time `json:"end"` } type OncallsResponse struct { Oncalls []Oncall `json:"oncalls"` Offset int `json:"offset"` Limit int `json:"limit"` More bool `json:"more"` Total int `json:"total,omitempty"` } type Override struct { Id string `json:"id"` Start string `json:"start"` End string `json:"end"` User UserRef `json:"user"` } ================================================ FILE: plugins/pagerduty/pd_user.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "io/ioutil" ) // https://v2.developer.pagerduty.com/v2/page/api-reference#!/On-Calls/get_oncalls func GetUsersOncall(token string) ([]Oncall, error) { out := make([]Oncall, 0) offset := 0 limit := 100 for { url := pagedUrl("/oncalls", offset, limit, nil) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return out, err } data, err := ioutil.ReadAll(resp.Body) oresp := OncallsResponse{} err = json.Unmarshal(data, &oresp) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return out, err } out = append(out, oresp.Oncalls...) if oresp.More { offset = offset + limit } else { break } } return out, nil } func GetUsers(token string, params map[string][]string) ([]User, error) { out := make([]User, 0) offset := 0 limit := 100 for { url := pagedUrl("/users", offset, limit, params) resp, err := authenticatedGet(url, token) if err != nil { log.Printf("GET %s failed: %s", url, err) return out, err } data, err := ioutil.ReadAll(resp.Body) uresp := UsersResponse{} err = json.Unmarshal(data, &uresp) if err != nil { log.Printf("json.Unmarshal failed: %s", err) return out, err } out = append(out, uresp.Users...) if uresp.More { offset = offset + limit } else { break } } return out, nil } ================================================ FILE: plugins/pagerduty/plugin.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "time" "github.com/netflix/hal-9001/hal" ) // the hal.secrets key that should contain the pagerduty auth token const PagerdutyTokenKey = `pagerduty.token` // the key name used for caching the full escalation policy const CacheKey = `pagerduty.policy_cache` const cacheExpire = time.Minute * 10 const DefaultCacheInterval = "1h" func Register() { // use a custom RE because !page might be "!page foo" or "!pagefoo" pg := hal.Plugin{ Name: "page", Func: page, Regex: "^[[:space:]]*!page", } pg.Register() oc := hal.Plugin{ Name: "oncall", Func: oncall, Init: oncallInit, Command: "oncall", } oc.Register() poller := hal.Plugin{ Name: "pd_poller", Func: pollerHandler, Init: pollerInit, Command: "pdpoller", } poller.Register() } // TODO: consider making the token key per-room so different rooms can use different tokens // doing this will require a separate cache object per token... func getSecrets() (token string, err error) { secrets := hal.Secrets() token = secrets.Get(PagerdutyTokenKey) if token == "" { err = fmt.Errorf("Your Pagerduty auth token does not seem to be configured. Please add the %q secret.", PagerdutyTokenKey) } if err != nil { log.Println(err) } return token, err } ================================================ FILE: plugins/pagerduty/poller.go ================================================ package pagerduty /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "strings" "time" "github.com/netflix/hal-9001/hal" ) // TODO: add a timestamp-based cleanup for old edges/attrs/etc. func pollerHandler(evt hal.Evt) { // nothing yet - TODO: add control code, e.g. force refresh } func pollerInit(inst *hal.Instance) { pf := hal.PeriodicFunc{ Name: "pagerduty-poller", Interval: time.Hour, Function: ingestPagerdutyAccount, } pf.Register() go pf.Start() } func ingestPagerdutyAccount() { token, err := getSecrets() if err != nil || token == "" { log.Printf("pagerduty: %s is not set up in hal.Secrets. Cannot continue.", PagerdutyTokenKey) return } ingestPDusers(token) ingestPDteams(token) ingestPDservices(token) ingestPDschedules(token) } func ingestPDusers(token string) { params := map[string][]string{"include[]": []string{"contact_methods"}} users, err := GetUsers(token, params) if err != nil { log.Printf("Could not retreive users from the Pagerduty API: %s", err) return } for _, user := range users { attrs := map[string]string{ "pd-user-id": user.Id, "name": user.Name, "email": user.Email, } // plug in the contact methods for _, cm := range user.ContactMethods { if strings.HasSuffix(cm.Type, "_reference") { log.Printf("contact methods not included in data: try adding include[]=contact_methods to the request") } else { attrs[cm.Type+"-id"] = cm.Id attrs[cm.Type] = cm.Address } } edges := []string{"name", "email", "phone_contact_method", "sms_contact_method"} logit(hal.Directory().Put(user.Id, "pd-user", attrs, edges)) for _, team := range user.Teams { logit(hal.Directory().PutNode(team.Id, "pd-team")) logit(hal.Directory().PutEdge(team.Id, "pd-team", user.Id, "pd-user")) } } } func ingestPDteams(token string) { teams, err := GetTeams(token, nil) if err != nil { log.Printf("Could not retreive teams from the Pagerduty API: %s", err) return } for _, team := range teams { attrs := map[string]string{ "pd-team-id": team.Id, "pd-team": team.Name, "pd-team-summary": team.Summary, "pd-team-description": team.Description, } logit(hal.Directory().Put(team.Id, "pd-team", attrs, []string{"pd-team-id"})) } } func ingestPDservices(token string) { params := map[string][]string{"include[]": []string{"integrations"}} services, err := GetServices(token, params) if err != nil { log.Printf("Could not retreive services from the Pagerduty API: %s", err) return } for _, service := range services { attrs := map[string]string{ "pd-service-id": service.Id, "pd-service": service.Name, "pd-service-description": service.Description, "pd-escalation-policy-id": service.EscalationPolicy.Id, } edges := []string{"pd-service-key", "pd-service-id", "pd-escalation-policy-id", "pd-integration-key"} logit(hal.Directory().Put(service.Id, "pd-service", attrs, edges)) for _, team := range service.Teams { logit(hal.Directory().PutNode(team.Id, "pd-team")) logit(hal.Directory().PutEdge(team.Id, "pd-team", service.Id, "pd-service")) } for _, igr := range service.Integrations { if igr.Type == "generic_email_inbound_integration" { logit(hal.Directory().PutNode(igr.IntegrationEmail, "pd-integration-email")) logit(hal.Directory().PutEdge(igr.IntegrationEmail, "pd-integration-email", service.Id, "pd-service")) for _, team := range service.Teams { logit(hal.Directory().PutEdge(igr.IntegrationEmail, "pd-integration-email", team.Id, "pd-team")) } } else if igr.Type == "events_api_v2_inbound_integration" || igr.Type == "generic_events_api_inbound_integration" { logit(hal.Directory().PutNode(igr.IntegrationKey, "pd-integration-key")) logit(hal.Directory().PutEdge(igr.IntegrationKey, "pd-integration-key", service.Id, "pd-service")) for _, team := range service.Teams { logit(hal.Directory().PutEdge(igr.IntegrationKey, "pd-integration-key", team.Id, "pd-team")) } } } } } func ingestPDschedules(token string) { schedules, err := GetSchedules(token, nil) if err != nil { log.Printf("Could not retreive schedules from the Pagerduty API: %s", err) return } for _, schedule := range schedules { attrs := map[string]string{ "pd-schedule-id": schedule.Id, "pd-schedule": schedule.Name, "pd-schedule-summary": schedule.Summary, } logit(hal.Directory().Put(schedule.Id, "pd-schedule", attrs, []string{"pd-schedule-id"})) for _, ep := range schedule.EscalationPolicies { logit(hal.Directory().PutNode(ep.Id, "pd-escalation-policy")) logit(hal.Directory().PutEdge(ep.Id, "pd-escalation-policy", schedule.Id, "pd-schedule")) } for _, user := range schedule.Users { logit(hal.Directory().PutNode(user.Id, "pd-user")) logit(hal.Directory().PutEdge(user.Id, "pd-user", schedule.Id, "pd-schedule")) } } } func logit(err error) { if err != nil { log.Println("pagerduty/hal_directory error: %s", err) } } ================================================ FILE: plugins/pluginmgr/plugin.go ================================================ // Package pluginmgr is a plugin manager for hal that allows users to // manage plugins from inside chat or over REST. package pluginmgr /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "time" "github.com/netflix/hal-9001/hal" ) var log hal.Logger // NAME of the plugin const NAME = "pluginmgr" // HELP text const HELP = ` Examples: !plugin list !plugin instances !plugin save !plugin attach --room !plugin attach --regex ^!foo !plugin detach !plugin group list !plugin group add !plugin group del e.g. !plugin attach uptime --room CORE !plugin detach uptime --room CORE !plugin save ` const PluginGroupTable = ` CREATE TABLE IF NOT EXISTS plugin_groups ( group_name VARCHAR(191), plugin_name VARCHAR(191), ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(group_name, plugin_name) )` type PluginGroupRow struct { Group string `json:"group"` Plugin string `json:"plugin"` Timestamp time.Time `json:"timestamp"` } type PluginGroup []*PluginGroupRow var cli *hal.Cmd // Register makes this plugin available to the system. func Register() { plugin := hal.Plugin{ Name: NAME, Func: pluginmgr, Command: "plugin", } plugin.Register() hal.SqlInit(PluginGroupTable) cli = hal.NewCmd("!plugin", true).SetUsage("Manage bot plugins.") cli.AddSubCmd("attach"). SetUsage("attach a plugin to the current or specified room with an optional command regex"). SubCmd().AddIdxParam(0, "plugin", true). SubCmd().AddIdxParam(1, "room", false). SubCmd().AddIdxParam(2, "regex", false) cli.AddSubCmd("detach"). SetUsage("detach a plugin from a room"). SubCmd().AddIdxParam(0, "plugin", true). SubCmd().AddIdxParam(1, "room", false). SubCmd().AddIdxParam(2, "regex", false) cli.AddSubCmd("save"). SetUsage("persist the configured plugins to the database") cli.AddSubCmd("list"). SetUsage("list the attached plugins") cli.AddSubCmd("instances"). SetUsage("list the available plugin instances"). SubCmd().AddIdxParam(0, "room", false) grp := cli.AddSubCmd("group") grp.SetUsage("Plugin groups.") grp.AddSubCmd("list"). AddIdxParam(0, "group", false) grp.AddSubCmd("add"). SubCmd().AddIdxParam(0, "group", true). SubCmd().AddIdxParam(1, "plugin", true) grp.AddSubCmd("del"). SubCmd().AddIdxParam(0, "group", true). SubCmd().AddIdxParam(1, "plugin", true) } func pluginmgr(evt hal.Evt) { req, err := cli.Process(evt.BodyAsArgv()) if err != nil { evt.Replyf("%s\n%s", err, cli.Usage()) return } sub := req.SubCmdInst() pr := hal.PluginRegistry() // read the param, check validity, return string plugin := func() string { name := sub.GetIdxParamInstByName("plugin").MustString() p, err := pr.GetPlugin(name) if err != nil { evt.Replyf("No such plugin: %q", name) return "" } return p.Name } // read the param, resolve name -> id as needed, return string room := func() string { // automatically defaults to the current room with or without the * r := evt.RoomId rp := sub.GetIdxParamInstByName("room") if rp.Found() { r = rp.MustString() } // the user may have provided --room with a room name // try to resolve a roomId with the broker, falling back to the name if evt.Broker != nil { roomId := evt.Broker.RoomNameToId(r) if roomId != "" { return roomId } } return r } // read the param, grab the plugin, return string w/ default from // the plugin metadata regex := func() string { // only needs to work with commands that require the plugin arg pn := sub.GetIdxParamInstByName("plugin").MustString() p, err := pr.GetPlugin(pn) if err != nil { return "" // doesn't matter, nothing works without a good plugin } return sub.GetIdxParamInstByName("regex").DefString(p.Regex) } switch req.SubCmdToken() { case "", "help": evt.Reply(cli.Usage()) case "attach": attachPlugin(evt, plugin(), room(), regex()) case "detach": detachPlugin(evt, plugin(), room()) case "save": savePlugins(evt) case "list": listPlugins(evt) case "instances": listInstances(evt, room()) case "group": gsub := sub.SubCmdInst() g := gsub.GetIdxParamInstByName("group").MustString() switch sub.SubCmdToken() { case "add": p := gsub.GetIdxParamInstByName("plugin").MustString() addGroupPlugin(evt, g, p) case "del": p := gsub.GetIdxParamInstByName("plugin").MustString() delGroupPlugin(evt, g, p) case "list": listGroupPlugin(evt, g) } } } func listPlugins(evt hal.Evt) { hdr := []string{"Plugin Name", "Default RE", "Status"} rows := [][]string{} pr := hal.PluginRegistry() for _, p := range pr.ActivePluginList() { row := []string{p.Name, p.Regex, "active"} rows = append(rows, row) } for _, p := range pr.InactivePluginList() { row := []string{p.Name, p.Regex, "inactive"} rows = append(rows, row) } evt.ReplyTable(hdr, rows) } func listInstances(evt hal.Evt, roomId string) { hdr := []string{"Plugin Name", "Broker", "Room", "RE"} rows := [][]string{} pr := hal.PluginRegistry() if roomId == "*" { roomId = evt.RoomId } for _, inst := range pr.InstanceList() { if roomId != "" && inst.RoomId != roomId { continue } row := []string{ inst.Plugin.Name, inst.Broker.Name(), inst.RoomId, inst.Regex, } rows = append(rows, row) } evt.ReplyTable(hdr, rows) } func savePlugins(evt hal.Evt) { pr := hal.PluginRegistry() err := pr.SaveInstances() if err != nil { evt.Replyf("Error while saving plugin config: %s", err) } else { evt.Reply("Plugin configuration saved.") } } func attachPlugin(evt hal.Evt, pluginName, roomId, regex string) { pr := hal.PluginRegistry() plugin, err := pr.GetPlugin(pluginName) if err != nil { evt.Replyf("No such plugin: '%s'", plugin) return } inst := plugin.Instance(roomId, evt.Broker) inst.RoomId = roomId inst.Regex = regex err = inst.Register() if err != nil { evt.Replyf("Failed to launch plugin '%s' in room id '%s': %s", plugin, roomId, err) } else { evt.Replyf("Launched an instance of plugin: '%s' in room id '%s'", plugin, roomId) } } func detachPlugin(evt hal.Evt, plugin, roomId string) { pr := hal.PluginRegistry() instances := pr.FindInstances(roomId, evt.BrokerName(), plugin) // there should be only one, for now just log if that is not the case if len(instances) > 1 { log.Printf("FindInstances(%q, %q) returned %d instances. Expected 0 or 1.", roomId, plugin, len(instances)) } else if len(instances) == 0 { evt.Replyf("No plugin named %q is attached to room %q.", plugin, roomId) } for _, inst := range instances { inst.Unregister() evt.Replyf("%q/%q unregistered", roomId, plugin) } } func GetPluginGroup(group string) (PluginGroup, error) { out := make(PluginGroup, 0) sql := `SELECT group_name, plugin_name FROM plugin_groups` params := []interface{}{} if group != "" { sql = sql + " WHERE group_name=?" params = []interface{}{&group} } db := hal.SqlDB() rows, err := db.Query(sql, params...) if err != nil { return out, err } defer rows.Close() for rows.Next() { pgr := PluginGroupRow{} // TODO: add timestamps back after making some helpers for time conversion // (code that was here didn't handle NULL) err := rows.Scan(&pgr.Group, &pgr.Plugin) if err != nil { log.Printf("PluginGroup row iteration failed: %s\n", err) break } out = append(out, &pgr) } return out, nil } func (pgr *PluginGroupRow) Save() error { sql := `INSERT INTO plugin_groups (group_name, plugin_name, ts) VALUES (?, ?, ?)` db := hal.SqlDB() _, err := db.Exec(sql, &pgr.Group, &pgr.Plugin, &pgr.Timestamp) return err } func (pgr *PluginGroupRow) Delete() error { sql := `DELETE FROM plugin_groups WHERE group_name=? AND plugin_name=?` db := hal.SqlDB() _, err := db.Exec(sql, &pgr.Group, &pgr.Plugin) return err } func listGroupPlugin(evt hal.Evt, group string) { pgs, err := GetPluginGroup("") if err != nil { evt.Replyf("Could not fetch plugin group list: %s", err) return } tbl := make([][]string, len(pgs)) for i, pgr := range pgs { tbl[i] = []string{pgr.Group, pgr.Plugin} } evt.ReplyTable([]string{"Group Name", "Plugin Name"}, tbl) } func addGroupPlugin(evt hal.Evt, group, pluginName string) { pr := hal.PluginRegistry() // make sure the plugin name is valid plugin, err := pr.GetPlugin(pluginName) if err != nil { evt.Error(err) return } // no checking for group other than "can it be inserted as a string" pgr := PluginGroupRow{ Group: group, Plugin: plugin.Name, Timestamp: time.Now(), } err = pgr.Save() if err != nil { evt.Replyf("failed to add %q to group %q: %s", pgr.Plugin, pgr.Group, err) } else { evt.Replyf("added %q to group %q", pgr.Plugin, pgr.Group) } } func delGroupPlugin(evt hal.Evt, group, plugin string) { pgr := PluginGroupRow{Group: group, Plugin: plugin} err := pgr.Delete() if err != nil { evt.Replyf("failed to delete %q from group %q: %s", pgr.Plugin, pgr.Group, err) } else { evt.Replyf("deleted %q from group %q", pgr.Plugin, pgr.Group) } } ================================================ FILE: plugins/prefmgr/http.go ================================================ package prefmgr /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "net/http" "github.com/netflix/hal-9001/hal" ) func prefHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: getPrefHandler(w, r) case http.MethodPut: putPrefHandler(w, r) case http.MethodPatch: patchPrefHandler(w, r) case http.MethodDelete: deletePrefHandler(w, r) } } // getPrefHandler returns all prefs as a JSON document. // There are currently no parameters for server-side filtering, that will // be done client-side. func getPrefHandler(w http.ResponseWriter, r *http.Request) { prefs := hal.FindPrefs("", "", "", "", "") bytes, err := json.Marshal(&prefs) if err != nil { log.Fatalf("Error while encoding prefs as JSON: %s", err) } _, err = w.Write(bytes) if err != nil { log.Fatalf("Error while sending JSON response: %s", err) } } func putPrefHandler(w http.ResponseWriter, r *http.Request) { } func patchPrefHandler(w http.ResponseWriter, r *http.Request) { } func deletePrefHandler(w http.ResponseWriter, r *http.Request) { } ================================================ FILE: plugins/prefmgr/plugin.go ================================================ // prefmgr exposes hal's preferences as a bot command and over REST package prefmgr /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "net/http" "regexp" "strings" "github.com/netflix/hal-9001/hal" ) const NAME = "prefmgr" const HELP = `Listing keys with no filter will list all keys visible to the active user and room. !prefs list --key KEY !prefs list --user USER --room CHANNEL --plugin PLUGIN --key KEY --def DEFAULT ` var cli *hal.Cmd var slackLinkRE *regexp.Regexp var log hal.Logger func init() { log.SetPrefix("plugins/prefmgr") } func init() { cli = hal.NewCmd("!pref", true).SetUsage("Manage hal preferences over chat.") keyUsage := "the key name, up to 190 utf8 characters" valueUsage := "the value, arbitrary utf8" roomUsage := "the chat room id (usually auto-resolved, '*' for 'this room')" userUsage := "the user id (usually auto-resolved, '*' for 'executing user')" brokerUsage := "the broker name. e.g. 'slack' ('*' for 'this broker')" pluginUsage := "the plugin name. e.g. 'archive' ('*' for 'this plugin')" cli.AddSubCmd("set"). SetUsage("set a preference key/value"). SubCmd().AddKVParam("key", true).AddAlias("k").SetUsage(keyUsage). SubCmd().AddKVParam("value", true).AddAlias("v").SetUsage(valueUsage). SubCmd().AddKVParam("room", false).AddAlias("r").SetUsage(roomUsage). SubCmd().AddKVParam("user", false).AddAlias("u").SetUsage(userUsage). SubCmd().AddKVParam("broker", false).AddAlias("b").SetUsage(brokerUsage). SubCmd().AddKVParam("plugin", false).AddAlias("p").SetUsage(pluginUsage) cli.AddSubCmd("list"). AddAlias("get"). SetUsage("retreive preferences, optionally filtered by the provided attributes"). SubCmd(). AddKVParam("key", false).AddAlias("k").SetUsage(keyUsage). SubCmd().AddKVParam("value", false).AddAlias("v").SetUsage(valueUsage). SubCmd().AddKVParam("room", false).AddAlias("r").SetUsage(roomUsage). SubCmd().AddKVParam("user", false).AddAlias("u").SetUsage(userUsage). SubCmd().AddKVParam("broker", false).AddAlias("b").SetUsage(brokerUsage). SubCmd().AddKVParam("plugin", false).AddAlias("p").SetUsage(pluginUsage) cli.AddSubCmd("find"). SetUsage("retreive preferences following precedence rules"). SubCmd().AddKVParam("key", false).AddAlias("k").SetUsage(keyUsage). SubCmd().AddKVParam("value", false).AddAlias("v").SetUsage(valueUsage). SubCmd().AddKVParam("room", false).AddAlias("r").SetUsage(roomUsage). SubCmd().AddKVParam("user", false).AddAlias("u").SetUsage(userUsage). SubCmd().AddKVParam("broker", false).AddAlias("b").SetUsage(brokerUsage). SubCmd().AddKVParam("plugin", false).AddAlias("p").SetUsage(pluginUsage) cli.AddSubCmd("rm"). SetUsage("delete a preference by id"). AddIdxParam(0, "id", true). SetUsage("the preference id to delete") slackLinkRE = regexp.MustCompile("^<(?:http|mailto):.*|.*>$") } func Register() { plugin := hal.Plugin{ Name: NAME, Func: prefmgr, Command: "pref", } plugin.Register() http.HandleFunc("/api/pref", prefHandler) } // prefmgr is called when someone executes !pref in the chat system func prefmgr(evt hal.Evt) { req, err := cli.Process(evt.BodyAsArgv()) if err != nil { // eww... switch err.(type) { case hal.SubCmdNotFound: evt.Reply(cli.Usage()) default: evt.Reply(err.Error()) } return } switch req.SubCmdToken() { case "", "help": evt.Reply(cli.Usage()) case "set": cliSet(req.SubCmdInst(), &evt) case "list": cliList(req.SubCmdInst(), &evt) case "find": cliFind(req.SubCmdInst(), &evt) case "rm": cliRm(req.SubCmdInst(), &evt) default: evt.Reply(req.Usage()) } } // cmd2pref copies data from the hal.Cmd and hal.Evt into a hal.Pref, resolving // *'s on the way. func cmd2pref(req *hal.SubCmdInst, evt *hal.Evt) (*hal.Pref, error) { var out hal.Pref for _, pi := range req.ListKVParamInsts() { var err error var key, value string switch pi.Key() { case "key": key, err = pi.String() out.Key = stripAutoLinks(key) case "value": value, err = pi.String() out.Value = stripAutoLinks(value) case "room": out.Room = pi.DefString(evt.RoomId) case "user": out.User = pi.DefString(evt.UserId) case "broker": out.Broker = pi.DefString(evt.BrokerName()) case "plugin": out.Plugin, _ = pi.String() } // return on the first error if err != nil { return nil, err } } return &out, nil } // cliList implements !pref list func cliList(req *hal.SubCmdInst, evt *hal.Evt) { opts := hal.Pref{} prefs := opts.Find() for _, pi := range req.ListKVParamInsts() { var err error var key, value string switch pi.Key() { case "key": key, err = pi.String() prefs = prefs.Key(stripAutoLinks(key)) case "value": value, err = pi.String() prefs = prefs.Value(stripAutoLinks(value)) case "room": prefs = prefs.Room(pi.DefString(evt.RoomId)) case "user": prefs = prefs.User(pi.DefString(evt.UserId)) case "broker": prefs = prefs.Broker(pi.DefString(evt.BrokerName())) case "plugin": prefs = prefs.Plugin(pi.DefString(NAME)) } if err != nil { evt.Error(err) return } } data := prefs.Table() evt.ReplyTable(data[0], data[1:]) } // cliFind implements !pref find func cliFind(req *hal.SubCmdInst, evt *hal.Evt) { opts, err := cmd2pref(req, evt) if err != nil { panic(err) // TODO: placeholder } prefs := opts.Find() data := prefs.Table() evt.ReplyTable(data[0], data[1:]) } // cliSet implements !pref set func cliSet(req *hal.SubCmdInst, evt *hal.Evt) { opts, err := cmd2pref(req, evt) if err != nil { panic(err) // TODO: placeholder } if opts.Room != "" && !evt.Broker.LooksLikeRoomId(opts.Room) { opts.Room = evt.Broker.RoomNameToId(opts.Room) } if opts.User != "" && !evt.Broker.LooksLikeUserId(opts.User) { opts.User = evt.Broker.UserNameToId(opts.User) } // TODO: check plugin name validity // TODO: check broker name validity fmt.Printf("Setting pref: %q\n", opts.String()) err = opts.Set() if err != nil { evt.Replyf("Failed to set pref: %q", err) } else { data := opts.GetPrefs().Table() evt.ReplyTable(data[0], data[1:]) } } // cliRm implements !pref rm func cliRm(req *hal.SubCmdInst, evt *hal.Evt) { id, err := req.GetIdxParamInst(0).Int() if err != nil { panic(err) // TODO: placeholder } err = hal.RmPrefId(id) if err != nil { evt.Replyf("Failed to delete pref with id %d: %s", id, err) } else { evt.Replyf("Deleted pref id %d.", id) } } func stripAutoLinks(in string) string { if slackLinkRE.MatchString(in) { parts := strings.Split(strings.TrimSuffix(in, ">"), "|") if len(parts) == 2 { return parts[1] } } return in } ================================================ FILE: plugins/roster/plugin.go ================================================ package roster /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "encoding/json" "fmt" "net/http" "time" "github.com/netflix/hal-9001/hal" ) var log hal.Logger type RosterUser struct { Broker string `json: broker` // broker name e.g. slack, hipchat User string `json: user` Room string `json: room` Timestamp time.Time `json: timestamp` } const ROSTER_TABLE = ` CREATE TABLE IF NOT EXISTS roster ( broker VARCHAR(191) NOT NULL, user VARCHAR(191) NOT NULL, room VARCHAR(191) DEFAULT NULL, ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (broker, user, room) )` func Register() { // rostertracker gets all messages and keeps a database of when users // were last seen to support !last, and the web roster. roster := hal.Plugin{ Name: "roster_tracker", Func: rostertracker, } roster.Register() rostercmd := hal.Plugin{ Name: "roster_command", Func: rosterlast, Regex: "!last", } rostercmd.Register() hal.SqlInit(ROSTER_TABLE) http.HandleFunc("/v1/roster", webroster) } // rostertracker is called for every message. It grabs the user and current // time and throws it into the db for later use. func rostertracker(msg hal.Evt) { db := hal.SqlDB() sql := `INSERT INTO roster (broker, user, room, ts) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE broker=?, user=?, room=?, ts=?` params := []interface{}{ msg.BrokerName(), msg.User, msg.Room, msg.Time, msg.BrokerName(), msg.User, msg.Room, msg.Time, } _, err := db.Exec(sql, params...) if err != nil { log.Printf("roster_tracker write failed: %s", err) } } // rosterlast is the response to !last that causes the bot to reply via DM // to the user with a table of when users last posted a message to slack // rather than relying on status, which is usually useless. func rosterlast(msg hal.Evt) { rus, err := GetRoster() if err != nil { log.Printf("Error while retreiving roster: %s\n", err) return } // TODO: ASCII art instead of JSON js, err := json.MarshalIndent(rus, "", " ") if err != nil { log.Printf("JSON marshaling failed: %s\n", err) return } msg.Replyf("```%s```", string(js)) } func webroster(w http.ResponseWriter, r *http.Request) { rus, err := GetRoster() if err != nil { http.Error(w, fmt.Sprintf("could not fetch roster: '%s'", err), 500) return } js, err := json.Marshal(rus) if err != nil { http.Error(w, fmt.Sprintf("could not marshal roster to json: '%s'", err), 500) return } w.Write(js) } func GetRoster() ([]*RosterUser, error) { db := hal.SqlDB() sql := `SELECT broker, user, room, UNIX_TIMESTAMP(ts) AS ts FROM roster ORDER BY ts DESC` rows, err := db.Query(sql) if err != nil { log.Printf("Roster query failed: %s\n", err) return nil, err } defer rows.Close() rus := []*RosterUser{} for rows.Next() { ru := RosterUser{} var ts int64 err = rows.Scan(&ru.Broker, &ru.User, &ru.Room, &ts) if err != nil { log.Printf("Row iteration failed: %s\n", err) return nil, err } ru.Timestamp = time.Unix(ts, 0) rus = append(rus, &ru) } return rus, nil } ================================================ FILE: plugins/seppuku/plugin.go ================================================ package seppuku /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "os" "time" "github.com/netflix/hal-9001/hal" ) var log hal.Logger func Register() { p := hal.Plugin{ Name: "seppuku", Func: seppuku, Regex: "^[[:space:]]*!(seppuku|切腹)", } p.Register() z := hal.Plugin{ Name: "zombie", Func: zombie, Regex: "^[[:space:]]*!(zombie|ゾンビ)", } z.Register() } // seppuku instructs the bot to die. // you probably don't want this on in production - if you do, a supervisor // is highly recommended func seppuku(evt hal.Evt) { evt.Reply("さようなら") time.Sleep(2 * time.Second) log.Printf("exiting due to %q command from %s in %s/%s", evt.Body, evt.User, evt.BrokerName(), evt.Room) os.Exit(1337) } // zombie disables all plugins but seppuku and stays running. // useful for putting a bot deployed under a supervisor out of comission // so a local copy can be tested without interference - put the bot into zombie // mode then when you're ready for it to die, instruct it to seppuku func zombie(evt hal.Evt) { pr := hal.PluginRegistry() for _, inst := range pr.InstanceList() { if inst.Plugin.Name == "zombie" { // this makes the hal router think zombie has executed for every // incoming event so it doesn't fall through and say "invalid command" inst.Regex = "" inst.Func = func(evt hal.Evt) { return } } else if inst.Plugin.Name != "seppuku" { inst.Unregister() } } evt.Reply("まったネクストライフ") } ================================================ FILE: plugins/spam/plugin.go ================================================ package spam /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "github.com/netflix/hal-9001/hal" ) var log hal.Logger func Register() { p := hal.Plugin{ Name: "spam", Func: spam, } p.Register() } func spam(evt hal.Evt) { response := evt.AsPref().SetUser("").FindKey("spam-response").One() if response.Success { evt.Reply(response.Value) } else { log.Printf("spam is configured in room %q but could not find the 'spam-response' pref", evt.RoomId) } } ================================================ FILE: plugins/uptime/plugin.go ================================================ // uptime: the simplest useful plugin possible package uptime /* * Copyright 2016-2017 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ( "fmt" "time" "github.com/netflix/hal-9001/hal" ) var booted time.Time func init() { booted = time.Now() } func Register() { p := hal.Plugin{ Name: "uptime", Func: uptime, Command: "uptime", } p.Register() } func uptime(evt hal.Evt) { ut := time.Since(booted) evt.Reply(fmt.Sprintf("uptime: %s", ut.String())) }