diff --git a/command/commands.go b/command/commands.go index 65ff5714..7503561f 100644 --- a/command/commands.go +++ b/command/commands.go @@ -16,6 +16,7 @@ import ( "github.com/innogames/slack-bot/v2/command/pool" "github.com/innogames/slack-bot/v2/command/pullrequest" "github.com/innogames/slack-bot/v2/command/queue" + "github.com/innogames/slack-bot/v2/command/ripeatlas" "github.com/innogames/slack-bot/v2/command/weather" ) @@ -73,6 +74,9 @@ func GetCommands(slackClient client.SlackClient, cfg config.Config) *bot.Command // openai/chatgpt commands.Merge(openai.GetCommands(base, &cfg)) + // Ripe Atlas + commands.Merge(ripeatlas.GetCommands(base, &cfg)) + return commands } diff --git a/command/ripeatlas/api.go b/command/ripeatlas/api.go new file mode 100644 index 00000000..d901a91e --- /dev/null +++ b/command/ripeatlas/api.go @@ -0,0 +1,182 @@ +package ripeatlas + +import ( + "encoding/json" + "fmt" + "time" +) + +type CreditsResponse struct { + CurrentBalance int `json:"current_balance"` + CreditChecked bool `json:"credit_checked"` + MaxDailyCredits int `json:"max_daily_credits"` + EstimatedDailyIncome int `json:"estimated_daily_income"` + EstimatedDailyExpenditure int `json:"estimated_daily_expenditure"` + EstimatedDailyBalance int `json:"estimated_daily_balance"` + CalculationTime string `json:"calculation_time"` + EstimatedRunoutSeconds any `json:"estimated_runout_seconds"` + PastDayMeasurementResults int `json:"past_day_measurement_results"` + PastDayCreditsSpent int `json:"past_day_credits_spent"` + LastDateDebited string `json:"last_date_debited"` + LastDateCredited string `json:"last_date_credited"` + IncomeItems string `json:"income_items"` + ExpenseItems string `json:"expense_items"` + Transactions string `json:"transactions"` +} + +type MeasurementsResponse struct { + Count int `json:"count,omitempty"` + Next string `json:"next,omitempty"` + Previous string `json:"previous,omitempty"` + Measurements []Measurement `json:"results"` +} + +type MeasurementResult struct { + Measurements []int `json:"measurements"` +} + +type Measurement struct { + AddressFamily int `json:"af"` + CreationTime int `json:"creation_time"` + CreditsPerResult int `json:"credits_per_result"` + Description string `json:"description"` + EstimatedResultsPerDay int `json:"estimated_results_per_day"` + Group string `json:"group"` + GroupID int64 `json:"group_id"` + ID int `json:"id"` + InWifiGroup bool `json:"in_wifi_group"` + IncludeProbeID bool `json:"include_probe_id"` + Interval int `json:"interval"` + IsAllScheduled bool `json:"is_all_scheduled"` + IsOneoff bool `json:"is_oneoff"` + IsPublic bool `json:"is_public"` + PacketInterval int64 `json:"packet_interval"` + Packets int `json:"packets"` + ParticipantCount int64 `json:"participant_count"` + ProbesRequested int64 `json:"probes_requested"` + ProbesScheduled int64 `json:"probes_scheduled"` + ResolveOnProbe bool `json:"resolve_on_probe"` + ResolvedIps string `json:"resolved_ips"` + Result string `json:"result"` + Size int64 `json:"size"` + Spread int64 `json:"spread"` + StartTime int `json:"start_time"` + Status MeasurementStatus `json:"status"` + StopTime int `json:"stop_time"` + Tags []string `json:"tags"` + Target string `json:"target"` + TargetAsn int64 `json:"target_asn"` + TargetIP string `json:"target_ip"` + TargetPrefix string `json:"target_prefix"` + Type string `json:"type"` +} +type MeasurementStatus struct { + Name string `json:"name"` + ID int `json:"id"` + When any `json:"when"` +} + +type MeasurementRequest struct { + Definitions []MeasurementDefinition `json:"definitions"` + Probes []Probes `json:"probes"` + IsOneOff bool `json:"is_oneoff"` +} +type MeasurementDefinition struct { + Target string `json:"target,omitempty"` + Af int `json:"af,omitempty"` + ResponseTimeout int `json:"response_timeout,omitempty"` + Description string `json:"description,omitempty"` + Protocol string `json:"protocol,omitempty"` + ResolveOnProbe bool `json:"resolve_on_probe,omitempty"` + Packets int `json:"packets,omitempty"` + Size int `json:"size,omitempty"` + FirstHop int `json:"first_hop,omitempty"` + MaxHops int `json:"max_hops,omitempty"` + Paris int `json:"paris,omitempty"` + DestinationOptionSize int `json:"destination_option_size,omitempty"` + HopByHopOptionSize int `json:"hop_by_hop_option_size,omitempty"` + DontFragment bool `json:"dont_fragment,omitempty"` + SkipDNSCheck bool `json:"skip_dns_check,omitempty"` + Type string `json:"type,omitempty"` + IsPublic bool `json:"is_public"` +} +type Probes struct { + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + Requested int `json:"requested,omitempty"` +} + +type StreamingResponse struct { + Type string `json:"type"` + Payload StreamingResponsePayload `json:"payload"` +} + +func (sr *StreamingResponse) UnmarshalJSON(b []byte) error { + a := []interface{}{&sr.Type, &sr.Payload} + return json.Unmarshal(b, &a) +} + +type StreamingResponsePayload struct { + Fw int `json:"fw,omitempty"` + Mver string `json:"mver,omitempty"` + Lts int `json:"lts,omitempty"` + Endtime int `json:"endtime,omitempty"` + DstName string `json:"dst_name,omitempty"` + DstAddr string `json:"dst_addr,omitempty"` + SrcAddr string `json:"src_addr,omitempty"` + Proto string `json:"proto,omitempty"` + Af int `json:"af,omitempty"` + Size int `json:"size,omitempty"` + ParisID int `json:"paris_id,omitempty"` + Result []PayloadResult `json:"result,omitempty"` + MsmID int `json:"msm_id,omitempty"` + PrbID int `json:"prb_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + MsmName string `json:"msm_name,omitempty"` + From string `json:"from,omitempty"` + Type string `json:"type,omitempty"` + GroupID int `json:"group_id,omitempty"` +} + +func (srp StreamingResponsePayload) String() string { + // Start: 2023-08-03T14:01:07Z + // HOST: 2a02:1811:c1c:7800:a62b:b0ff:fef1:5062 Loss% Last + // 1 . AS0 172.20.0.1 0% 0.139 + // 2 . AS0 172.26.4.1 0% 0.397 + // 3 . AS0 192.168.144.1 0% 1.693 + var text string + text += "```\n" + text += fmt.Sprintf("Start: %s\n", time.Unix(srp.Timestamp, 0)) + text += fmt.Sprintf("HOST: %-40s Loss%% RTT\n", srp.SrcAddr) + + for _, res := range srp.Result { + var from string + switch { + case len(res.Result[0].From) > 0: + from = res.Result[0].From + case len(res.Result[1].From) > 0: + from = res.Result[1].From + case len(res.Result[2].From) > 0: + from = res.Result[2].From + default: + from = "???" + } + + text += fmt.Sprintf("%2d . %-40s %4d%% %7.3f %7.3f %7.3f\n", res.Hop, from, 0, res.Result[0].Rtt, res.Result[1].Rtt, res.Result[2].Rtt) + } + + text += "```\n" + return text +} + +type HopResult struct { + From string `json:"from,omitempty"` + TTL int `json:"ttl,omitempty"` + Size int `json:"size,omitempty"` + Rtt float64 `json:"rtt,omitempty"` + Loss string `json:"x,omitempty"` +} +type PayloadResult struct { + Hop int `json:"hop,omitempty"` + Result []HopResult `json:"result,omitempty"` +} diff --git a/command/ripeatlas/commands.go b/command/ripeatlas/commands.go new file mode 100644 index 00000000..6a0827a8 --- /dev/null +++ b/command/ripeatlas/commands.go @@ -0,0 +1,27 @@ +package ripeatlas + +import ( + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/config" +) + +var category = bot.Category{ + Name: "RIPE Atlas", + Description: "Run queries against the RIPE Atlas API to debug network issues", +} + +func GetCommands(base bot.BaseCommand, config *config.Config) bot.Commands { + var commands bot.Commands + + cfg := loadConfig(config) + if !cfg.IsEnabled() { + return commands + } + + commands.AddCommand( + &creditsCommand{base, cfg}, + &tracerouteCommand{base, cfg}, + ) + + return commands +} diff --git a/command/ripeatlas/config.go b/command/ripeatlas/config.go new file mode 100644 index 00000000..581ef1cf --- /dev/null +++ b/command/ripeatlas/config.go @@ -0,0 +1,33 @@ +package ripeatlas + +import ( + "time" + + "github.com/innogames/slack-bot/v2/bot/config" +) + +// Config configuration: API key to do API calls +type Config struct { + APIKey string `mapstructure:"api_key"` + APIURL string `mapstructure:"api_url"` + StreamURL string `mapstructure:"stream_url"` + UpdateInterval time.Duration `mapstructure:"update_interval"` +} + +// IsEnabled checks if token is set +func (c *Config) IsEnabled() bool { + return c.APIKey != "" +} + +var defaultConfig = Config{ + APIURL: "https://atlas.ripe.net/api/v2", + StreamURL: "https://atlas-stream.ripe.net/stream/", + UpdateInterval: time.Second, +} + +func loadConfig(config *config.Config) Config { + cfg := defaultConfig + _ = config.LoadCustom("ripeatlas", &cfg) + + return cfg +} diff --git a/command/ripeatlas/credits.go b/command/ripeatlas/credits.go new file mode 100644 index 00000000..b6bf8859 --- /dev/null +++ b/command/ripeatlas/credits.go @@ -0,0 +1,76 @@ +package ripeatlas + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/matcher" + "github.com/innogames/slack-bot/v2/bot/msg" + "github.com/innogames/slack-bot/v2/client" + log "github.com/sirupsen/logrus" +) + +type creditsCommand struct { + bot.BaseCommand + cfg Config +} + +func (c *creditsCommand) GetMatcher() matcher.Matcher { + return matcher.NewGroupMatcher( + matcher.NewTextMatcher("credits", c.credits), + ) +} + +func (c *creditsCommand) credits(_ matcher.Result, message msg.Message) { + c.AddReaction(":coffee:", message) + defer c.RemoveReaction(":coffee:", message) + + url := fmt.Sprintf("%s/credits", c.cfg.APIURL) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + c.ReplyError(message, fmt.Errorf("request creation returned an err: %w", err)) + log.Errorf("request creation returned an err: %s", err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Key "+c.cfg.APIKey) + + response, err := client.GetHTTPClient().Do(req) + if err != nil { + c.ReplyError(message, fmt.Errorf("API call returned an err: %w", err)) + log.Errorf("API call returned an err: %s", err) + return + } + defer response.Body.Close() + + if response.StatusCode >= 400 { + c.ReplyError(message, fmt.Errorf("API call returned an err: %d", response.StatusCode)) + log.Errorf("API call returned an err: %d", response.StatusCode) + return + } + + var result CreditsResponse + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + c.ReplyError(message, err) + log.Errorf("%s", err) + return + } + + text := fmt.Sprintf("Total credits remaining: %d", result.CurrentBalance) + + c.SendMessage(message, text) +} + +func (c *creditsCommand) GetHelp() []bot.Help { + return []bot.Help{ + { + Command: "credits", + Description: "Query how many credits are available for this API key", + Category: category, + }, + } +} diff --git a/command/ripeatlas/ripeatlas_test.go b/command/ripeatlas/ripeatlas_test.go new file mode 100644 index 00000000..e89f6ce1 --- /dev/null +++ b/command/ripeatlas/ripeatlas_test.go @@ -0,0 +1,233 @@ +package ripeatlas + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/config" + "github.com/innogames/slack-bot/v2/bot/msg" + "github.com/innogames/slack-bot/v2/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func spawnRIPEAtlasServer(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + authenticate := func(res http.ResponseWriter, req *http.Request) bool { + assert.NotNil(t, req.Header.Get("Authorization")) + + authHeader := req.Header.Get("Authorization") + if authHeader != "Key apikey" { + res.WriteHeader(http.StatusForbidden) + res.Write([]byte(`{"error":{"detail":"The provided API key does not exist","status":403,"title":"Forbidden","code":104}}`)) + return false + } + return true + } + + // test connection + mux.HandleFunc("/", func(res http.ResponseWriter, r *http.Request) { + // mock stream response + if r.URL.RawQuery == "streamType=result&msm=58913886" { + res.Write([]byte(`["atlas_subscribed",{"streamType":"result","msm":1001}]` + "\n")) + res.Write([]byte(`["atlas_result",{"msm_id": 58913886,"timestamp":1234567890,"result":[{"hop":1,"result":[{"from":"whatever1.host","size":100},{"from":"whatever2.host","size":100},{"from":"whatever3.host","size":100}]}]}]` + "\n")) + return + } + + res.Write([]byte(`ok`)) + }) + + mux.HandleFunc("/credits", func(res http.ResponseWriter, req *http.Request) { + // Ensure body is empty + givenInputJSON, _ := io.ReadAll(req.Body) + assert.Empty(t, string(givenInputJSON)) + + // Check for authentication + if !authenticate(res, req) { + return + } + + res.WriteHeader(http.StatusOK) + res.Write([]byte(`{ "current_balance": 998060, + "credit_checked": true, + "max_daily_credits": 1000000, + "estimated_daily_income": 0, + "estimated_daily_expenditure": 0, + "estimated_daily_balance": 0, + "calculation_time": "2023-08-23T15:15:49.274480", + "estimated_runout_seconds": null, + "past_day_measurement_results": 3, + "past_day_credits_spent": 180, + "last_date_debited": "2023-08-22T00:18:47.516307", + "last_date_credited": "2022-03-10T22:22:28.728145", + "income_items": "https://atlas.ripe.net/api/v2/credits/income-items/", + "expense_items": "https://atlas.ripe.net/api/v2/credits/expense-items/", + "transactions": "https://atlas.ripe.net/api/v2/credits/transactions/"}`)) + }) + + mux.HandleFunc("/measurements", func(res http.ResponseWriter, req *http.Request) { + // Check for authentication + if !authenticate(res, req) { + return + } + + expectedJSON := `{"definitions":[{"target":"8.8.8.8","af":4,"description":"Slackbot measurement to 8.8.8.8","protocol":"ICMP","packets":3,"type":"traceroute","is_public":true}],"probes":[{"type":"area","value":"WW","requested":1}],"is_oneoff":true}` + responseJSON := `{"measurements":[58913886]}` + + givenInputJSON, _ := io.ReadAll(req.Body) + assert.Equal(t, expectedJSON, string(givenInputJSON)) + + res.WriteHeader(http.StatusOK) + res.Write([]byte(responseJSON)) + }) + + return httptest.NewServer(mux) +} + +func TestRipeAtlas(t *testing.T) { + slackClient := &mocks.SlackClient{} + base := bot.BaseCommand{SlackClient: slackClient} + + t.Run("RIPE Atlas is not active", func(t *testing.T) { + cfg := &config.Config{} + commands := GetCommands(base, cfg) + assert.Equal(t, 0, commands.Count()) + }) + + t.Run("RIPE Atlas is active", func(t *testing.T) { + ripeAtlasCfg := defaultConfig + ripeAtlasCfg.APIKey = "apikey" + + cfg := &config.Config{} + cfg.Set("ripeatlas", ripeAtlasCfg) + commands := GetCommands(base, cfg) + assert.Equal(t, 2, commands.Count()) + + help := commands.GetHelp() + assert.Equal(t, 2, len(help)) + }) + + t.Run("RIPE Atlas Credits API wrong key", func(t *testing.T) { + // mock RIPE Atlas API + ts := spawnRIPEAtlasServer(t) + defer ts.Close() + + ripeAtlasCfg := defaultConfig + ripeAtlasCfg.APIKey = "nope" + ripeAtlasCfg.APIURL = ts.URL + + cfg := &config.Config{} + cfg.Set("ripeatlas", ripeAtlasCfg) + commands := GetCommands(base, cfg) + + message := msg.Message{} + message.Text = "credits" + + mocks.AssertReaction(slackClient, ":coffee:", message) + mocks.AssertRemoveReaction(slackClient, ":coffee:", message) + mocks.AssertError(slackClient, message, "API call returned an err: 403") + + actual := commands.Run(message) + time.Sleep(100 * time.Millisecond) + assert.True(t, actual) + }) + + t.Run("RIPE Atlas Credits API works", func(t *testing.T) { + // mock RIPE Atlas API + ts := spawnRIPEAtlasServer(t) + defer ts.Close() + + ripeAtlasCfg := defaultConfig + ripeAtlasCfg.APIKey = "apikey" + ripeAtlasCfg.APIURL = ts.URL + + cfg := &config.Config{} + cfg.Set("ripeatlas", ripeAtlasCfg) + commands := GetCommands(base, cfg) + + message := msg.Message{} + message.Text = "credits" + + mocks.AssertReaction(slackClient, ":coffee:", message) + mocks.AssertRemoveReaction(slackClient, ":coffee:", message) + mocks.AssertSlackMessage(slackClient, message, "Total credits remaining: 998060") + + actual := commands.Run(message) + time.Sleep(100 * time.Millisecond) + assert.True(t, actual) + }) + + t.Run("RIPE Atlas Traceroute Destination Parsing", func(t *testing.T) { + assert.Equal(t, parseDestination("8.8.8.8"), 4) + assert.Equal(t, parseDestination("2001:4860:4860::8844"), 6) + assert.Equal(t, parseDestination("example.com"), 6) + }) + + t.Run("RIPE Atlas Traceroute API wrong key", func(t *testing.T) { + // mock RIPE Atlas API + ts := spawnRIPEAtlasServer(t) + defer ts.Close() + + ripeAtlasCfg := defaultConfig + ripeAtlasCfg.APIKey = "nope" + ripeAtlasCfg.APIURL = ts.URL + ripeAtlasCfg.StreamURL = ts.URL + + cfg := &config.Config{} + cfg.Set("ripeatlas", ripeAtlasCfg) + commands := GetCommands(base, cfg) + + message := msg.Message{} + message.Text = "traceroute 8.8.8.8" + + mocks.AssertReaction(slackClient, ":stopwatch:", message) + mocks.AssertRemoveReaction(slackClient, ":stopwatch:", message) + mocks.AssertError(slackClient, message, "API call returned an err: 403") + + actual := commands.Run(message) + time.Sleep(100 * time.Millisecond) + assert.True(t, actual) + }) + + t.Run("RIPE Atlas Traceroute API works", func(t *testing.T) { + // Set a proper timezone, otherwise the test fails on GitHub Actions + time.Local, _ = time.LoadLocation("Europe/Berlin") + + // mock RIPE Atlas API + ts := spawnRIPEAtlasServer(t) + defer ts.Close() + + ripeAtlasCfg := defaultConfig + ripeAtlasCfg.APIKey = "apikey" + ripeAtlasCfg.APIURL = ts.URL + ripeAtlasCfg.StreamURL = ts.URL + + cfg := &config.Config{} + cfg.Set("ripeatlas", ripeAtlasCfg) + commands := GetCommands(base, cfg) + + message := msg.Message{} + message.Text = "traceroute 8.8.8.8" + + mocks.AssertReaction(slackClient, ":stopwatch:", message) + mocks.AssertRemoveReaction(slackClient, ":stopwatch:", message) + mocks.AssertSlackMessage(slackClient, message, "Measurement created: https://atlas.ripe.net/measurements/58913886\n", mock.Anything) + expectedResult := "```\n" + + "Start: 2009-02-14 00:31:30 +0100 CET\n" + + "HOST: Loss% RTT\n" + + " 1 . whatever1.host 0% 0.000 0.000 0.000\n" + + "```\n" + mocks.AssertSlackMessage(slackClient, message, expectedResult, mock.Anything) + + actual := commands.Run(message) + time.Sleep(100 * time.Millisecond) + assert.True(t, actual) + }) +} diff --git a/command/ripeatlas/traceroute.go b/command/ripeatlas/traceroute.go new file mode 100644 index 00000000..f3801c58 --- /dev/null +++ b/command/ripeatlas/traceroute.go @@ -0,0 +1,171 @@ +package ripeatlas + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/netip" + "time" + + "github.com/innogames/slack-bot/v2/bot" + "github.com/innogames/slack-bot/v2/bot/matcher" + "github.com/innogames/slack-bot/v2/bot/msg" + "github.com/innogames/slack-bot/v2/client" + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" +) + +type tracerouteCommand struct { + bot.BaseCommand + cfg Config +} + +func (c *tracerouteCommand) GetMatcher() matcher.Matcher { + return matcher.NewGroupMatcher( + matcher.NewRegexpMatcher(`traceroute (?P.*)`, c.traceroute), + ) +} + +func parseDestination(destination string) int { + var af int + address, err := netip.ParseAddr(destination) + if err != nil { + af = 6 + } else { + if address.Is4() { + af = 4 + } else { + af = 6 + } + } + + return af +} + +func (c *tracerouteCommand) traceroute(match matcher.Result, message msg.Message) { + destination := match.GetString("TGT") + + c.AddReaction(":stopwatch:", message) + defer c.RemoveReaction(":stopwatch:", message) + + af := parseDestination(destination) + + jsonData, _ := json.Marshal(MeasurementRequest{ + Definitions: []MeasurementDefinition{ + { + Af: af, + Target: destination, + Description: fmt.Sprintf("Slackbot measurement to %s", destination), + Type: "traceroute", + Protocol: "ICMP", + Packets: 3, + ResolveOnProbe: false, + Paris: 0, + IsPublic: true, + }, + }, + Probes: []Probes{ + { + Type: "area", + Value: "WW", + Requested: 1, + }, + }, + IsOneOff: true, + }) + + url := fmt.Sprintf("%s/measurements", c.cfg.APIURL) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + c.ReplyError(message, fmt.Errorf("request creation returned an err: %w", err)) + log.Errorf("request creation returned an err: %s", err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Key "+c.cfg.APIKey) + + response, err := client.GetHTTPClient().Do(req) + if err != nil { + c.ReplyError(message, fmt.Errorf("HTTP Client Error: %w", err)) + log.Errorf("HTTP Client Error: %s", err) + return + } + defer response.Body.Close() + + if response.StatusCode >= 400 { + c.ReplyError(message, fmt.Errorf("API call returned an err: %d", response.StatusCode)) + log.Errorf("API call returned an err: %d", response.StatusCode) + return + } + + body, _ := io.ReadAll(response.Body) + + var measurementResult MeasurementResult + err = json.Unmarshal(body, &measurementResult) + + if err != nil { + c.ReplyError(message, fmt.Errorf("error unmarshalling MeasurementResult: %w", err)) + log.Errorf("error unmarshalling MeasurementResult: %s", err) + return + } + + c.SendMessage( + message, + fmt.Sprintf("Measurement created: https://atlas.ripe.net/measurements/%d\n", measurementResult.Measurements[0]), + slack.MsgOptionTS(message.GetTimestamp()), + ) + + subscribeURL := fmt.Sprintf("%s?streamType=result&msm=%d", c.cfg.StreamURL, measurementResult.Measurements[0]) + + client := http.Client{Timeout: 240 * time.Second} + response, err = client.Get(subscribeURL) + if err != nil { + c.ReplyError(message, fmt.Errorf("error when unsubscribing to results stream: %w", err)) + log.Errorf("error when unsubscribing to results stream: %s", err) + return + } + defer response.Body.Close() + fileScanner := bufio.NewScanner(response.Body) + fileScanner.Split(bufio.ScanLines) + for fileScanner.Scan() { + line := fileScanner.Bytes() + + var streamResponse StreamingResponse + err = json.Unmarshal(line, &streamResponse) + if err != nil { + c.ReplyError(message, fmt.Errorf("error unmarshalling streamResponse: %w", err)) + log.Errorf("Error unmarshalling streamResponse: %s", err) + return + } + + switch streamResponse.Type { + case "atlas_subscribed": + log.Debugf("Successfully subscribed to measurement") + case "atlas_result": + content := streamResponse.Payload.String() + c.SendMessage( + message, + content, + slack.MsgOptionTS(message.GetTimestamp()), + ) + return + } + } +} + +func (c *tracerouteCommand) GetHelp() []bot.Help { + return []bot.Help{ + { + Command: "traceroute ", + Description: "Sends a traceroute to the given destination", + Category: category, + Examples: []string{ + "traceroute 8.8.8.8", + }, + }, + } +}