commit 66cf67d3899174ebf27ba7aba50663d16e02d97e Author: Space-Banane Date: Wed Jan 21 22:55:58 2026 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6df005b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +users.json +.env +data/ +*.exe diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..ed3c94c --- /dev/null +++ b/Readme.md @@ -0,0 +1,70 @@ +# Thoughtful Discord Bot + +This project is a Discord bot that integrates with your Thoughtful instance, allowing users to create, list, and delete ideas directly from Discord using slash commands and simple text commands. + +## ✨ Features +- **Setup**: Configure your Thoughtful instance URL and API key using `/setup`. +- **Create Ideas**: Use `.thought Title; Description` in any channel to create a new idea. +- **List Ideas**: Use `/list` to view all your ideas. +- **Delete Ideas**: Use `/delete` with autocomplete to remove an idea by title. +- **Logout**: Use `/logout` to remove your stored credentials. + +## 🚀 Quick Deployment +You can deploy this bot easily using Docker Compose. Follow these steps: + +### 1. Clone the Repository +```sh +git clone https://github.com/yourusername/thoughtful-dcbob.git +cd thoughtful-dcbob +``` + +Or, if updating an existing deployment: +```sh +git pull +``` + +### 2. Set Up Environment Variables +Create a `.env` file in the project root with your Discord bot token: + +``` +DISCORD_BOT_TOKEN=your_discord_bot_token_here +``` + +### 3. Run with Docker Compose +A sample `docker-compose.yml` is provided. Start the bot with: + +```sh +docker compose up -d +``` + +This will build and run the bot in the background. + +## 🤖 Usage +1. **Invite your bot to your Discord server.** +2. **Run `/setup`** to configure your Thoughtful instance (URL and API key). +3. **Create ideas** by typing `.thought Title; Description` in any channel. +4. **List ideas** with `/list`. +5. **Delete ideas** with `/delete` (autocomplete helps you find the right idea). +6. **Logout** with `/logout`. + +## 🔧 Configuration Storage +User configuration (instance URL and API key) is stored in `users.json` in the project directory. This file is managed automatically by the bot. + +## ⚠️ Intents ⚠️ +The bot requires the following Discord intents: +- Message Content Intent + +## 🤔 Troubleshooting +- Make sure your Discord bot token is correct and set in the environment. +- Ensure your Thoughtful instance is reachable from the bot. +- For any issues, check the logs with: + ```sh + docker compose logs + ``` + +## Contributing +Pull requests and issues are welcome! + +--- + +**Enjoy using Thoughtful Discord Bot!** diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..e2e8136 --- /dev/null +++ b/bot.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + + "thoughtful-dcbob/commands" + + "github.com/bwmarrin/discordgo" +) + +func main() { + // Create a new Discord session using the provided bot token. + token := os.Getenv("DISCORD_BOT_TOKEN") + if token == "" { + fmt.Println("DISCORD_BOT_TOKEN not set; aborting") + return + } + dg, err := discordgo.New("Bot " + token) + if err != nil { + fmt.Println("error creating Discord session,", err) + return + } + + // Register handlers + dg.AddHandler(messageCreate) + dg.AddHandler(func(s *discordgo.Session, ic *discordgo.InteractionCreate) { + commands.HandleInteraction(s, ic) + }) + + // Just like the ping pong example, we only care about receiving message + // events in this example. + dg.Identify.Intents = discordgo.IntentsGuildMessages + + // Open a websocket connection to Discord and begin listening. + err = dg.Open() + if err != nil { + fmt.Println("error opening connection,", err) + return + } + + // After opening, register slash commands. + if err := commands.RegisterAll(dg); err != nil { + fmt.Println("error registering commands:", err) + // continue running; commands may be registered later + } + + // Wait here until CTRL-C or other term signal is received. + fmt.Println("Bot is now running. Press CTRL-C to exit.") + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + <-sc + + // Cleanly close down the Discord session. + dg.Close() +} + +// This function will be called (due to AddHandler above) every time a new +// message is created on any channel that the authenticated bot has access to. +// +// It is called whenever a message is created but only when it's sent through a +// server as we did not request IntentsDirectMessages. +func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + // Ignore all messages created by the bot itself + // This isn't required in this specific example but it's a good practice. + if m.Author.ID == s.State.User.ID { + return + } + // Non-slash command: .thought Title; Description + if strings.HasPrefix(m.Content, ".thought") { + cfg, ok, err := commands.GetUserConfig(m.Author.ID) + if err != nil { + s.ChannelMessageSend(m.ChannelID, "Storage error.") + return + } + if !ok { + s.ChannelMessageSend(m.ChannelID, "Please run `/setup` first to configure your Thoughtful instance.") + return + } + + rest := strings.TrimSpace(m.Content[len(".thought"):]) + if rest == "" { + s.ChannelMessageSend(m.ChannelID, "Usage: .thought Title; Description") + return + } + parts := strings.SplitN(rest, ";", 2) + title := strings.TrimSpace(parts[0]) + desc := "" + if len(parts) > 1 { + desc = strings.TrimSpace(parts[1]) + } + + payload := map[string]string{"title": title, "description": desc} + b, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", cfg.InstanceURL+"/api/ideas/create", bytes.NewReader(b)) + req.Header.Set("API-Authentication", cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + s.ChannelMessageSend(m.ChannelID, "Failed to send to API: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + s.ChannelMessageSend(m.ChannelID, "Thought created successfully!") + } else { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Failed to create thought. Status: %d", resp.StatusCode)) + } + return + } +} diff --git a/commands/commands.go b/commands/commands.go new file mode 100644 index 0000000..40b9268 --- /dev/null +++ b/commands/commands.go @@ -0,0 +1,71 @@ +package commands + +import ( + "fmt" + "log" + + "github.com/bwmarrin/discordgo" +) + +type Cmd struct { + Command *discordgo.ApplicationCommand + Handler func(s *discordgo.Session, ic *discordgo.InteractionCreate) + AutocompleteHandler func(s *discordgo.Session, ic *discordgo.InteractionCreate) +} + +var ( + commandHandlers = map[string]func(s *discordgo.Session, ic *discordgo.InteractionCreate){} + autocompleteHandlers = map[string]func(s *discordgo.Session, ic *discordgo.InteractionCreate){} +) + +func getAllCommands() []*Cmd { + return []*Cmd{ + newSetupCommand(), + newLogoutCommand(), + newListCommand(), + newDeleteCommand(), + } +} + +// RegisterAll registers the application commands with Discord and stores handlers. +func RegisterAll(s *discordgo.Session) error { + // Clean up old commands (optional, but good for dev) + // existing, _ := s.ApplicationCommands(s.State.User.ID, "") + // for _, v := range existing { + // s.ApplicationCommandDelete(s.State.User.ID, "", v.ID) + // } + + for _, c := range getAllCommands() { + _, err := s.ApplicationCommandCreate(s.State.User.ID, "", c.Command) + if err != nil { + return fmt.Errorf("creating command %s: %w", c.Command.Name, err) + } + commandHandlers[c.Command.Name] = c.Handler + if c.AutocompleteHandler != nil { + autocompleteHandlers[c.Command.Name] = c.AutocompleteHandler + } + } + log.Println("Commands registered.") + return nil +} + +// HandleInteraction routes incoming interaction events to the right handler. +func HandleInteraction(s *discordgo.Session, ic *discordgo.InteractionCreate) { + switch ic.Type { + case discordgo.InteractionApplicationCommand: + name := ic.ApplicationCommandData().Name + if h, ok := commandHandlers[name]; ok { + h(s, ic) + } + case discordgo.InteractionApplicationCommandAutocomplete: + name := ic.ApplicationCommandData().Name + if h, ok := autocompleteHandlers[name]; ok { + h(s, ic) + } + case discordgo.InteractionModalSubmit: + data := ic.ModalSubmitData() + if data.CustomID == "setup_modal" { + handleSetupSubmit(s, ic) + } + } +} diff --git a/commands/delete.go b/commands/delete.go new file mode 100644 index 0000000..340d805 --- /dev/null +++ b/commands/delete.go @@ -0,0 +1,139 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/bwmarrin/discordgo" +) + +func newDeleteCommand() *Cmd { + return &Cmd{ + Command: &discordgo.ApplicationCommand{ + Name: "delete", + Description: "Delete a Thoughtful idea", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "idea", + Description: "The idea to delete (search by title)", + Required: true, + Autocomplete: true, + }, + }, + }, + Handler: handleDelete, + AutocompleteHandler: handleDeleteAutocomplete, + } +} + +func handleDelete(s *discordgo.Session, ic *discordgo.InteractionCreate) { + user := ic.User + if user == nil { + user = ic.Member.User + } + cfg, ok, err := GetUserConfig(user.ID) + if err != nil { + respondError(s, ic, "Error retrieving configuration.") + return + } + if !ok { + respondError(s, ic, "You are not logged in. Use `/setup` first.") + return + } + + options := ic.ApplicationCommandData().Options + ideaID := options[0].StringValue() + + payload := map[string]string{"id": ideaID} + b, _ := json.Marshal(payload) + + req, _ := http.NewRequest("DELETE", cfg.InstanceURL+"/api/ideas/delete", bytes.NewReader(b)) + req.Header.Set("API-Authentication", cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + respondError(s, ic, "Failed to connect: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + respondError(s, ic, "Unauthorized.") + return + } + + // Assuming 200 is success + if resp.StatusCode == 200 { + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Idea deleted successfully.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + } else { + respondError(s, ic, fmt.Sprintf("Failed to delete. Status: %d", resp.StatusCode)) + } +} + +func handleDeleteAutocomplete(s *discordgo.Session, ic *discordgo.InteractionCreate) { + user := ic.User + if user == nil { + user = ic.Member.User + } + cfg, ok, err := GetUserConfig(user.ID) + if err != nil || !ok { + return + } + + // Fetch ideas + client := &http.Client{} + req, _ := http.NewRequest("GET", cfg.InstanceURL+"/api/ideas/list", nil) + req.Header.Set("API-Authentication", cfg.APIKey) + + resp, err := client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + var result ideaListResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return + } + + query := "" + data := ic.ApplicationCommandData() + for _, opt := range data.Options { + if opt.Name == "idea" && opt.Focused { + query = strings.ToLower(opt.StringValue()) + break + } + } + + choices := []*discordgo.ApplicationCommandOptionChoice{} + for _, idea := range result.Ideas { + if query == "" || strings.Contains(strings.ToLower(idea.Title), query) { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: idea.Title, + Value: idea.ID, + }) + if len(choices) >= 25 { + break + } + } + } + + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices, + }, + }) +} diff --git a/commands/list.go b/commands/list.go new file mode 100644 index 0000000..da7c6b8 --- /dev/null +++ b/commands/list.go @@ -0,0 +1,115 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bwmarrin/discordgo" +) + +func newListCommand() *Cmd { + return &Cmd{ + Command: &discordgo.ApplicationCommand{ + Name: "list", + Description: "List your Thoughtful ideas", + }, + Handler: handleList, + } +} + +type ideaListResponse struct { + Success bool `json:"success"` + Ideas []Idea `json:"ideas"` +} + +type Idea struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` +} + +func handleList(s *discordgo.Session, ic *discordgo.InteractionCreate) { + user := ic.User + if user == nil { + user = ic.Member.User + } + cfg, ok, err := GetUserConfig(user.ID) + if err != nil { + respondError(s, ic, "Error retrieving configuration.") + return + } + if !ok { + respondError(s, ic, "You are not logged in. Use `/setup` first.") + return + } + + client := &http.Client{} + req, _ := http.NewRequest("GET", cfg.InstanceURL+"/api/ideas/list", nil) + req.Header.Set("API-Authentication", cfg.APIKey) + + resp, err := client.Do(req) + if err != nil { + respondError(s, ic, "Failed to connect to Thoughtful instance: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + respondError(s, ic, "Unauthorized. Check your API Key.") + return + } + if resp.StatusCode != 200 { + respondError(s, ic, fmt.Sprintf("API Error: %d", resp.StatusCode)) + return + } + + var result ideaListResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + respondError(s, ic, "Failed to parse API response.") + return + } + + if !result.Success { + respondError(s, ic, "API reported failure.") + return + } + + if len(result.Ideas) == 0 { + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "No ideas found.", + }, + }) + return + } + + // Format output (simple list for now) + var content = "**Your Ideas:**\n" + for _, idea := range result.Ideas { + content += fmt.Sprintf("• **%s** (ID: `%s`)\n", idea.Title, idea.ID) + } + + // Discord message limit check (simplified) + if len(content) > 2000 { + content = content[:1997] + "..." + } + + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + }, + }) +} + +func respondError(s *discordgo.Session, ic *discordgo.InteractionCreate, msg string) { + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/commands/logout.go b/commands/logout.go new file mode 100644 index 0000000..8271dc8 --- /dev/null +++ b/commands/logout.go @@ -0,0 +1,35 @@ +package commands + +import ( + "github.com/bwmarrin/discordgo" +) + +func newLogoutCommand() *Cmd { + return &Cmd{ + Command: &discordgo.ApplicationCommand{ + Name: "logout", + Description: "Remove your stored Thoughtful credentials", + }, + Handler: handleLogout, + } +} + +func handleLogout(s *discordgo.Session, ic *discordgo.InteractionCreate) { + user := ic.User + if user == nil { + user = ic.Member.User + } + err := DeleteUserConfig(user.ID) + content := "Your credentials have been removed." + if err != nil { + content = "Error removing credentials: " + err.Error() + } + + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/commands/setup.go b/commands/setup.go new file mode 100644 index 0000000..504b3c8 --- /dev/null +++ b/commands/setup.go @@ -0,0 +1,112 @@ +package commands + +import ( + "strings" + + "github.com/bwmarrin/discordgo" +) + +func newSetupCommand() *Cmd { + return &Cmd{ + Command: &discordgo.ApplicationCommand{ + Name: "setup", + Description: "Configure your Thoughtful instance", + }, + Handler: handleSetup, + } +} + +func handleSetup(s *discordgo.Session, ic *discordgo.InteractionCreate) { + err := s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "setup_modal", + Title: "Thoughtful Setup", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "instance_url", + Label: "Instance URL", + Style: discordgo.TextInputShort, + Placeholder: "https://your-thoughtful-instance.com", + Required: true, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "api_key", + Label: "API Key", + Style: discordgo.TextInputShort, + Placeholder: "Your API Key", + Required: true, + }, + }, + }, + }, + }, + }) + if err != nil { + // fallback if modal fails (rare) + } +} + +func handleSetupSubmit(s *discordgo.Session, ic *discordgo.InteractionCreate) { + data := ic.ModalSubmitData() + url := "" + key := "" + + for _, comp := range data.Components { + // ActionsRow -> TextInput + if row, ok := comp.(*discordgo.ActionsRow); ok { + for _, c := range row.Components { + if input, ok := c.(*discordgo.TextInput); ok { + if input.CustomID == "instance_url" { + url = strings.TrimSpace(input.Value) + } else if input.CustomID == "api_key" { + key = strings.TrimSpace(input.Value) + } + } + } + } + } + + // Normalize URL + url = strings.TrimSuffix(url, "/") + + if url == "" || key == "" { + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Both URL and API Key are required.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + user := ic.User + if user == nil { + user = ic.Member.User + } + + err := SaveUserConfig(user.ID, UserConfig{ + InstanceURL: url, + APIKey: key, + }) + + content := "Configuration saved successfully!" + if err != nil { + content = "Error saving configuration: " + err.Error() + } + + s.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/commands/storage.go b/commands/storage.go new file mode 100644 index 0000000..3f7ed28 --- /dev/null +++ b/commands/storage.go @@ -0,0 +1,84 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +type UserConfig struct { + InstanceURL string `json:"instance_url"` + APIKey string `json:"api_key"` +} + +var ( + storePath = filepath.Join("users.json") + mu sync.Mutex +) + +func ensureDir() error { + dir := filepath.Dir(storePath) + return os.MkdirAll(dir, 0o755) +} + +func GetUserConfig(userID string) (UserConfig, bool, error) { + m, err := loadAll() + if err != nil { + return UserConfig{}, false, err + } + cfg, ok := m[userID] + return cfg, ok, nil +} + +func SaveUserConfig(userID string, cfg UserConfig) error { + m, err := loadAll() + if err != nil { + // if load fails, maybe just start fresh? + m = make(map[string]UserConfig) + } + m[userID] = cfg + return saveAll(m) +} + +func DeleteUserConfig(userID string) error { + m, err := loadAll() + if err != nil { + return err + } + delete(m, userID) + return saveAll(m) +} + +func loadAll() (map[string]UserConfig, error) { + mu.Lock() + defer mu.Unlock() + if err := ensureDir(); err != nil { + return nil, err + } + if _, err := os.Stat(storePath); os.IsNotExist(err) { + return map[string]UserConfig{}, nil + } + b, err := os.ReadFile(storePath) + if err != nil { + return nil, err + } + var m map[string]UserConfig + if err := json.Unmarshal(b, &m); err != nil { + return map[string]UserConfig{}, nil // return empty on corrupt + } + return m, nil +} + +func saveAll(m map[string]UserConfig) error { + mu.Lock() + defer mu.Unlock() + if err := ensureDir(); err != nil { + return err + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(storePath, b, 0o644) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1642e7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + bot: + image: golang:1.25 + working_dir: /app + volumes: + - ./:/app + command: if [ ! -f .env ]; then echo ".env file not found! Please create one based on .env.example and set your DISCORD_BOT_TOKEN."; exit 1; fi && go mod tidy && go run . + env_file: + - .env + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aeedec7 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module thoughtful-dcbob + +go 1.20 + +require github.com/bwmarrin/discordgo v0.29.0 + +require ( + github.com/JexSrs/go-ollama v1.1.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..99b8be3 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/JexSrs/go-ollama v1.1.1 h1:+dggIeIxkVUgLq2k6l3bJ+5dtHr2+NTacYIz/bjshzE= +github.com/JexSrs/go-ollama v1.1.1/go.mod h1:OjK/woUpcpAszwEABTcjwOOy2Sf/q+BYLvU7oAPKVVs= +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=