Repository: favonia/cloudflare-ddns
Pull request #772 (2024-06-26 12:36:41)

feat(notifier): send more messages (part 5 of shoutrrr support)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
Attention: Patch coverage is `50.00000%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 90.72%. Comparing base [(`6ddeb70`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/6ddeb70c60a8f52449fb30813fd8434a8ca757fd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) to head [(`66c16bb`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/66c16bb532312a4147961abfd359ce095056dee8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).

| [Files](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Patch % | Lines |
|---|---|---|
| [cmd/ddns/ddns.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772?src=pr&el=tree&filepath=cmd%2Fddns%2Fddns.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-Y21kL2RkbnMvZGRucy5nbw==) | 0.00% | [5 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #772      +/-   ##
==========================================
- Coverage   90.93%   90.72%   -0.22%     
==========================================
  Files          52       52              
  Lines        2129     2134       +5     
==========================================
  Hits         1936     1936              
- Misses        182      187       +5     
  Partials       11       11              
```

| [Flag](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | `90.72% <50.00%> (-0.22%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/772?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index b4e5152..637f678 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -57,8 +57,8 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, setter.Setter
 func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) {
 	if c.DeleteOnStop {
 		resp := updater.DeleteIPs(ctx, ppfmt, c, s)
-		monitor.SendResponseAll(ctx, ppfmt, c.Monitors, resp, false)
-		notifier.SendResponseAll(ctx, ppfmt, c.Notifiers, resp)
+		monitor.LogMessageAll(ctx, ppfmt, c.Monitors, resp)
+		notifier.SendMessageAll(ctx, ppfmt, c.Notifiers, resp)
 	}
 }
 
@@ -92,23 +92,29 @@ func realMain() int { //nolint:funlen
 
 	// Read the config and get the handler and the setter
 	c, s, configOk := initConfig(ctx, ppfmt)
-	// Ping the monitor regardless of whether initConfig succeeded
+	// Ping monitors regardless of whether initConfig succeeded
 	monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())
 	// Bail out now if initConfig failed
 	if !configOk {
-		monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "Config errors")
+		monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "Configuration errors")
+		notifier.SendAll(ctx, ppfmt, c.Notifiers,
+			"Cloudflare DDNS was misconfigured. Please check the logging for more details.")
 		ppfmt.Infof(pp.EmojiBye, "Bye!")
 		return 1
 	}
+	// If UPDATE_CRON is not `@once` (not single-run mode), then send a notification to signal the start.
+	if c.UpdateCron != nil {
+		notifier.SendAll(ctx, ppfmt, c.Notifiers, "Started running Cloudflare DDNS.")
+	}
 
+	// Without the following line, the quiet mode can be too quiet, and some system (Portainer)
+	// is not happy with completely empty log. As a workaround, we will print a Notice here.
+	// See GitHub issue #426.
+	//
+	// We still want to keep the quiet mode extremely quiet for the single-run mode (UPDATE_CRON=@once),
+	// hence we are checking whether cron is enabled or not. (The single-run mode is defined as
+	// having the internal cron disabled.)
 	if c.UpdateCron != nil && !ppfmt.IsEnabledFor(pp.Verbose) {
-		// Without the following line, the quiet mode can be too quiet, and some system (Portainer)
-		// is not happy with completely empty log. As a workaround, we will print a Notice here.
-		// See GitHub issue #426.
-		//
-		// We still want to keep the quiet mode extremely quiet for the single-run mode (UPDATE_CRON=@once),
-		// hence we are checking whether cron is enabled or not. (The single-run mode is defined as
-		// having the internal cron disabled.)
 		ppfmt.Noticef(pp.EmojiMute, "Quiet mode enabled")
 	}
 
@@ -123,8 +129,8 @@ func realMain() int { //nolint:funlen
 			monitor.SuccessAll(ctx, ppfmt, c.Monitors, "Started (no action)")
 		} else {
 			resp := updater.UpdateIPs(ctxWithSignals, ppfmt, c, s)
-			monitor.SendResponseAll(ctx, ppfmt, c.Monitors, resp, true)
-			notifier.SendResponseAll(ctx, ppfmt, c.Notifiers, resp)
+			monitor.PingMessageAll(ctx, ppfmt, c.Monitors, resp)
+			notifier.SendMessageAll(ctx, ppfmt, c.Notifiers, resp)
 		}
 
 		// Check if cron was disabled
@@ -137,9 +143,19 @@ func realMain() int { //nolint:funlen
 
 		// If there's nothing scheduled in near future
 		if next.IsZero() {
-			ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
+			ppfmt.Errorf(pp.EmojiUserError,
+				"No scheduled updates in near future; consider changing UPDATE_CRON=%s",
+				cron.DescribeSchedule(c.UpdateCron),
+			)
 			stopUpdating(ctx, ppfmt, c, s)
 			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "No scheduled updates")
+			notifier.SendAll(ctx, ppfmt, c.Notifiers,
+				fmt.Sprintf(
+					"Cloudflare DDNS stopped because there are no scheduled updates in near future. "+
+						"Consider changing the value of UPDATE_CRON (%s).",
+					cron.DescribeSchedule(c.UpdateCron),
+				),
+			)
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 1
 		}
@@ -150,7 +166,10 @@ func realMain() int { //nolint:funlen
 		// Wait for the next signal or the alarm, whichever comes first
 		if !sig.SleepUntil(ppfmt, next) {
 			stopUpdating(ctx, ppfmt, c, s)
-			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, "Terminated")
+			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, "Stopped")
+			if c.UpdateCron != nil {
+				notifier.SendAll(ctx, ppfmt, c.Notifiers, "Cloudflare DDNS stopped.")
+			}
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 0
 		}
diff --git a/internal/response/response.go b/internal/message/message.go
similarity index 54%
rename from internal/response/response.go
rename to internal/message/message.go
index e183d7d..ddd98b2 100644
--- a/internal/response/response.go
+++ b/internal/message/message.go
@@ -1,33 +1,33 @@
-package response
+package message
 
-type Response struct {
+type Message struct {
 	Ok               bool
 	MonitorMessages  []string
 	NotifierMessages []string
 }
 
-func NewEmpty() Response {
-	return Response{
+func NewEmpty() Message {
+	return Message{
 		Ok:               true,
 		MonitorMessages:  nil,
 		NotifierMessages: nil,
 	}
 }
 
-func Merge(rs ...Response) Response {
+func Merge(msgs ...Message) Message {
 	var (
 		allOk               = true
 		allMonitorMessages  = map[bool][]string{true: {}, false: {}}
 		allNotifierMessages = []string{}
 	)
 
-	for _, r := range rs {
-		allOk = allOk && r.Ok
-		allMonitorMessages[r.Ok] = append(allMonitorMessages[r.Ok], r.MonitorMessages...)
-		allNotifierMessages = append(allNotifierMessages, r.NotifierMessages...)
+	for _, msg := range msgs {
+		allOk = allOk && msg.Ok
+		allMonitorMessages[msg.Ok] = append(allMonitorMessages[msg.Ok], msg.MonitorMessages...)
+		allNotifierMessages = append(allNotifierMessages, msg.NotifierMessages...)
 	}
 
-	return Response{
+	return Message{
 		Ok:               allOk,
 		MonitorMessages:  allMonitorMessages[allOk],
 		NotifierMessages: allNotifierMessages,
diff --git a/internal/monitor/base.go b/internal/monitor/base.go
index 8f3a4bb..e90a969 100644
--- a/internal/monitor/base.go
+++ b/internal/monitor/base.go
@@ -5,8 +5,8 @@ import (
 	"context"
 	"strings"
 
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 //go:generate mockgen -typed -destination=../mocks/mock_monitor.go -package=mocks . Monitor
@@ -36,15 +36,22 @@ type Monitor interface {
 	ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool
 }
 
-func SendResponse(ctx context.Context, ppfmt pp.PP, m Monitor, r response.Response, ping bool) bool {
-	msg := strings.Join(r.MonitorMessages, "\n")
+func PingMessage(ctx context.Context, ppfmt pp.PP, m Monitor, msg message.Message) bool {
+	monitorMsg := strings.Join(msg.MonitorMessages, "\n")
+	if msg.Ok {
+		return m.Success(ctx, ppfmt, monitorMsg)
+	} else {
+		return m.Failure(ctx, ppfmt, monitorMsg)
+	}
+}
+
+func LogMessage(ctx context.Context, ppfmt pp.PP, m Monitor, msg message.Message) bool {
+	monitorMsg := strings.Join(msg.MonitorMessages, "\n")
 	switch {
-	case !r.Ok:
-		return m.Failure(ctx, ppfmt, msg)
-	case ping:
-		return m.Success(ctx, ppfmt, msg)
-	case len(r.MonitorMessages) > 0:
-		return m.Log(ctx, ppfmt, msg)
+	case !msg.Ok:
+		return m.Failure(ctx, ppfmt, monitorMsg)
+	case len(msg.MonitorMessages) > 0:
+		return m.Log(ctx, ppfmt, monitorMsg)
 	default:
 		return true
 	}
diff --git a/internal/monitor/composite.go b/internal/monitor/composite.go
index bfac28e..5afbdc0 100644
--- a/internal/monitor/composite.go
+++ b/internal/monitor/composite.go
@@ -3,8 +3,8 @@ package monitor
 import (
 	"context"
 
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 // DescribeAll calls [Monitor.Describe] for each monitor in the group with the callback.
@@ -69,11 +69,22 @@ func ExitStatusAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, code int, mes
 	return ok
 }
 
-// SendResponseAll calls [SendResponse] for each monitor in ms.
-func SendResponseAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, resp response.Response, ping bool) bool {
+// PingMessageAll calls [SendMessage] for each monitor in ms.
+func PingMessageAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, msg message.Message) bool {
 	ok := true
 	for _, m := range ms {
-		if !SendResponse(ctx, ppfmt, m, resp, ping) {
+		if !PingMessage(ctx, ppfmt, m, msg) {
+			ok = false
+		}
+	}
+	return ok
+}
+
+// LogMessageAll calls [SendMessage] for each monitor in ms.
+func LogMessageAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, msg message.Message) bool {
+	ok := true
+	for _, m := range ms {
+		if !LogMessage(ctx, ppfmt, m, msg) {
 			ok = false
 		}
 	}
diff --git a/internal/notifier/base.go b/internal/notifier/base.go
index e4bd980..01bb7e9 100644
--- a/internal/notifier/base.go
+++ b/internal/notifier/base.go
@@ -5,8 +5,8 @@ import (
 	"context"
 	"strings"
 
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 //go:generate mockgen -typed -destination=../mocks/mock_notifier.go -package=mocks . Notifier
@@ -20,9 +20,9 @@ type Notifier interface {
 	Send(ctx context.Context, ppfmt pp.PP, msg string) bool
 }
 
-func SendResponse(ctx context.Context, ppfmt pp.PP, n Notifier, r response.Response) bool {
-	if len(r.NotifierMessages) == 0 {
+func SendMessage(ctx context.Context, ppfmt pp.PP, n Notifier, msg message.Message) bool {
+	if len(msg.NotifierMessages) == 0 {
 		return true
 	}
-	return n.Send(ctx, ppfmt, strings.Join(r.NotifierMessages, " "))
+	return n.Send(ctx, ppfmt, strings.Join(msg.NotifierMessages, " "))
 }
diff --git a/internal/notifier/composite.go b/internal/notifier/composite.go
index 8e71d20..09e332b 100644
--- a/internal/notifier/composite.go
+++ b/internal/notifier/composite.go
@@ -3,8 +3,8 @@ package notifier
 import (
 	"context"
 
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 // DescribeAll calls [Notifier.Describe] for each monitor in the group with the callback.
@@ -25,11 +25,11 @@ func SendAll(ctx context.Context, ppfmt pp.PP, ns []Notifier, message string) bo
 	return ok
 }
 
-// SendResponseAll calls [SendResponse] for each monitor in the group.
-func SendResponseAll(ctx context.Context, ppfmt pp.PP, ns []Notifier, r response.Response) bool {
+// SendMessageAll calls [SendMessage] for each monitor in the group.
+func SendMessageAll(ctx context.Context, ppfmt pp.PP, ns []Notifier, msg message.Message) bool {
 	ok := true
 	for _, n := range ns {
-		if !SendResponse(ctx, ppfmt, n, r) {
+		if !SendMessage(ctx, ppfmt, n, msg) {
 			ok = false
 		}
 	}
diff --git a/internal/updater/response.go b/internal/updater/message.go
similarity index 83%
rename from internal/updater/response.go
rename to internal/updater/message.go
index 8a4fad0..3d072e9 100644
--- a/internal/updater/response.go
+++ b/internal/updater/message.go
@@ -7,7 +7,7 @@ import (
 
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
-	"github.com/favonia/cloudflare-ddns/internal/response"
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
 
@@ -31,23 +31,23 @@ func ListEnglishJoin(items []string) string {
 	}
 }
 
-func GenerateDetectResponse(ipNet ipnet.Type, ok bool) response.Response {
+func GenerateDetectMessage(ipNet ipnet.Type, ok bool) message.Message {
 	if ok {
-		return response.NewEmpty()
+		return message.NewEmpty()
 	}
 
-	return response.Response{
+	return message.Message{
 		Ok:               false,
 		MonitorMessages:  []string{fmt.Sprintf("Failed to detect %s address", ipNet.Describe())},
 		NotifierMessages: []string{fmt.Sprintf("Failed to detect the %s address.", ipNet.Describe())},
 	}
 }
 
-func GenerateUpdateResponse(ipNet ipnet.Type, ip netip.Addr, s SetterResponses) response.Response {
+func GenerateUpdateMessage(ipNet ipnet.Type, ip netip.Addr, s SetterResponses) message.Message {
 	switch {
 	case len(s[setter.ResponseFailed]) > 0 &&
 		len(s[setter.ResponseUpdated]) > 0:
-		return response.Response{
+		return message.Message{
 			Ok: false,
 			MonitorMessages: []string{fmt.Sprintf(
 				"Failed to set %s (%s): %s",
@@ -65,7 +65,7 @@ func GenerateUpdateResponse(ipNet ipnet.Type, ip netip.Addr, s SetterResponses)
 		}
 
 	case len(s[setter.ResponseFailed]) > 0:
-		return response.Response{
+		return message.Message{
 			Ok: false,
 			MonitorMessages: []string{fmt.Sprintf(
 				"Failed to set %s (%s): %s",
@@ -82,7 +82,7 @@ func GenerateUpdateResponse(ipNet ipnet.Type, ip netip.Addr, s SetterResponses)
 		}
 
 	case len(s[setter.ResponseUpdated]) > 0:
-		return response.Response{
+		return message.Message{
 			Ok: true,
 			MonitorMessages: []string{fmt.Sprintf(
 				"Set %s (%s): %s",
@@ -99,15 +99,15 @@ func GenerateUpdateResponse(ipNet ipnet.Type, ip netip.Addr, s SetterResponses)
 		}
 
 	default:
-		return response.Response{Ok: true, MonitorMessages: []string{}, NotifierMessages: []string{}}
+		return message.Message{Ok: true, MonitorMessages: []string{}, NotifierMessages: []string{}}
 	}
 }
 
-func GenerateDeleteResponse(ipNet ipnet.Type, s SetterResponses) response.Response {
+func GenerateDeleteMessage(ipNet ipnet.Type, s SetterResponses) message.Message {
 	switch {
 	case len(s[setter.ResponseFailed]) > 0 &&
 		len(s[setter.ResponseUpdated]) > 0:
-		return response.Response{
+		return message.Message{
 			Ok: false,
 			MonitorMessages: []string{fmt.Sprintf(
 				"Failed to delete %s: %s",
@@ -123,7 +123,7 @@ func GenerateDeleteResponse(ipNet ipnet.Type, s SetterResponses) response.Respon
 		}
 
 	case len(s[setter.ResponseFailed]) > 0:
-		return response.Response{
+		return message.Message{
 			Ok: false,
 			MonitorMessages: []string{fmt.Sprintf(
 				"Failed to delete %s: %s",
@@ -138,7 +138,7 @@ func GenerateDeleteResponse(ipNet ipnet.Type, s SetterResponses) response.Respon
 		}
 
 	case len(s[setter.ResponseUpdated]) > 0:
-		return response.Response{
+		return message.Message{
 			Ok: true,
 			MonitorMessages: []string{fmt.Sprintf(
 				"Deleted %s: %s",
@@ -153,6 +153,6 @@ func GenerateDeleteResponse(ipNet ipnet.Type, s SetterResponses) response.Respon
 		}
 
 	default:
-		return response.Response{Ok: true, MonitorMessages: []string{}, NotifierMessages: []string{}}
+		return message.Message{Ok: true, MonitorMessages: []string{}, NotifierMessages: []string{}}
 	}
 }
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 6ab7103..31caabe 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -10,8 +10,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
 
@@ -52,7 +52,7 @@ var errSettingTimeout = errors.New("setting timeout")
 // ip must be non-zero.
 func setIP(ctx context.Context, ppfmt pp.PP,
 	c *config.Config, s setter.Setter, ipNet ipnet.Type, ip netip.Addr,
-) response.Response {
+) message.Message {
 	resps := SetterResponses{}
 
 	for _, domain := range c.Domains[ipNet] {
@@ -71,13 +71,13 @@ func setIP(ctx context.Context, ppfmt pp.PP,
 		}
 	}
 
-	return GenerateUpdateResponse(ipNet, ip, resps)
+	return GenerateUpdateMessage(ipNet, ip, resps)
 }
 
 // deleteIP extracts relevant settings from the configuration and calls [setter.Setter.Delete] with a deadline.
 func deleteIP(
 	ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter, ipNet ipnet.Type,
-) response.Response {
+) message.Message {
 	resps := SetterResponses{}
 
 	for _, domain := range c.Domains[ipNet] {
@@ -88,12 +88,12 @@ func deleteIP(
 		resps.Register(resp, domain)
 	}
 
-	return GenerateDeleteResponse(ipNet, resps)
+	return GenerateDeleteMessage(ipNet, resps)
 }
 
 func detectIP(ctx context.Context, ppfmt pp.PP,
 	c *config.Config, ipNet ipnet.Type, use1001 bool,
-) (netip.Addr, bool) {
+) (netip.Addr, message.Message) {
 	ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout)
 	defer cancel()
 
@@ -114,37 +114,37 @@ func detectIP(ctx context.Context, ppfmt pp.PP,
 		}
 	}
 	ShouldDisplayHints[getHintIDForDetection(ipNet)] = false
-	return ip, ok
+	return ip, GenerateDetectMessage(ipNet, ok)
 }
 
 // UpdateIPs detect IP addresses and update DNS records of managed domains.
-func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) response.Response {
-	var resps []response.Response
+func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) message.Message {
+	var msgs []message.Message
 
 	for _, ipNet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 		if c.Provider[ipNet] != nil {
-			ip, ok := detectIP(ctx, ppfmt, c, ipNet, c.Use1001)
-			resps = append(resps, GenerateDetectResponse(ipNet, ok))
+			ip, msg := detectIP(ctx, ppfmt, c, ipNet, c.Use1001)
+			msgs = append(msgs, msg)
 
 			// Note: If we can't detect the new IP address,
 			// it's probably better to leave existing records alone.
-			if ok {
-				resps = append(resps, setIP(ctx, ppfmt, c, s, ipNet, ip))
+			if msg.Ok {
+				msgs = append(msgs, setIP(ctx, ppfmt, c, s, ipNet, ip))
 			}
 		}
 	}
-	return response.Merge(resps...)
+	return message.Merge(msgs...)
 }
 
 // DeleteIPs removes all DNS records of managed domains.
-func DeleteIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) response.Response {
-	var resps []response.Response
+func DeleteIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) message.Message {
+	var msgs []message.Message
 
 	for _, ipNet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 		if c.Provider[ipNet] != nil {
-			resps = append(resps, deleteIP(ctx, ppfmt, c, s, ipNet))
+			msgs = append(msgs, deleteIP(ctx, ppfmt, c, s, ipNet))
 		}
 	}
 
-	return response.Merge(resps...)
+	return message.Merge(msgs...)
 }


Test patch:
diff --git a/internal/monitor/composite_test.go b/internal/monitor/composite_test.go
index 274320d..d8a7703 100644
--- a/internal/monitor/composite_test.go
+++ b/internal/monitor/composite_test.go
@@ -8,9 +8,9 @@ import (
 
 	"go.uber.org/mock/gomock"
 
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 func TestDescribeAll(t *testing.T) {
@@ -125,7 +125,7 @@ func TestExitStatusAll(t *testing.T) {
 	monitor.ExitStatusAll(context.Background(), mockPP, ms, 42, message)
 }
 
-func TestSendResponseAll(t *testing.T) {
+func TestPingMessageAll(t *testing.T) {
 	t.Parallel()
 
 	notifierMessages := []string{"ocean", "moon"}
@@ -141,13 +141,10 @@ func TestSendResponseAll(t *testing.T) {
 		monitorMessage := strings.Join(tc1.monitorMessages, "\n")
 
 		for name2, tc2 := range map[string]struct {
-			ok   bool
-			ping bool
+			ok bool
 		}{
-			"success": {true, true},
-			"log":     {true, false},
-			"fail1":   {false, true},
-			"fail2":   {false, false},
+			"ok":    {true},
+			"notok": {false},
 		} {
 			t.Run(fmt.Sprintf("%s/%s", name1, name2), func(t *testing.T) {
 				t.Parallel()
@@ -158,9 +155,56 @@ func TestSendResponseAll(t *testing.T) {
 
 				for range 5 {
 					m := mocks.NewMockMonitor(mockCtrl)
-					switch {
-					case tc2.ok && tc2.ping:
+					if tc2.ok {
 						m.EXPECT().Success(context.Background(), mockPP, monitorMessage)
+					} else {
+						m.EXPECT().Failure(context.Background(), mockPP, monitorMessage)
+					}
+					ms = append(ms, m)
+				}
+
+				msg := message.Message{
+					Ok:               tc2.ok,
+					MonitorMessages:  tc1.monitorMessages,
+					NotifierMessages: notifierMessages,
+				}
+				monitor.PingMessageAll(context.Background(), mockPP, ms, msg)
+			})
+		}
+	}
+}
+
+func TestLogMessageAll(t *testing.T) {
+	t.Parallel()
+
+	notifierMessages := []string{"ocean", "moon"}
+
+	for name1, tc1 := range map[string]struct {
+		monitorMessages []string
+	}{
+		"nil":   {nil},
+		"empty": {[]string{}},
+		"one":   {[]string{"hi"}},
+		"two":   {[]string{"hi", "hey"}},
+	} {
+		monitorMessage := strings.Join(tc1.monitorMessages, "\n")
+
+		for name2, tc2 := range map[string]struct {
+			ok bool
+		}{
+			"ok":    {true},
+			"notok": {false},
+		} {
+			t.Run(fmt.Sprintf("%s/%s", name1, name2), func(t *testing.T) {
+				t.Parallel()
+
+				ms := make([]monitor.Monitor, 0, 5)
+				mockCtrl := gomock.NewController(t)
+				mockPP := mocks.NewMockPP(mockCtrl)
+
+				for range 5 {
+					m := mocks.NewMockMonitor(mockCtrl)
+					switch {
 					case tc2.ok && len(monitorMessage) > 0:
 						m.EXPECT().Log(context.Background(), mockPP, monitorMessage)
 					case tc2.ok:
@@ -170,12 +214,12 @@ func TestSendResponseAll(t *testing.T) {
 					ms = append(ms, m)
 				}
 
-				resp := response.Response{
+				msg := message.Message{
 					Ok:               tc2.ok,
 					MonitorMessages:  tc1.monitorMessages,
 					NotifierMessages: notifierMessages,
 				}
-				monitor.SendResponseAll(context.Background(), mockPP, ms, resp, tc2.ping)
+				monitor.LogMessageAll(context.Background(), mockPP, ms, msg)
 			})
 		}
 	}
diff --git a/internal/notifier/composite_test.go b/internal/notifier/composite_test.go
index 1c7f349..203b4f9 100644
--- a/internal/notifier/composite_test.go
+++ b/internal/notifier/composite_test.go
@@ -8,9 +8,9 @@ import (
 
 	"go.uber.org/mock/gomock"
 
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/notifier"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 func TestDescribeAll(t *testing.T) {
@@ -49,7 +49,7 @@ func TestSendAll(t *testing.T) {
 	notifier.SendAll(context.Background(), mockPP, ms, message)
 }
 
-func TestSendResponseAll(t *testing.T) {
+func TestSendMessageAll(t *testing.T) {
 	t.Parallel()
 
 	monitorMessages := []string{"forest", "grass"}
@@ -85,12 +85,12 @@ func TestSendResponseAll(t *testing.T) {
 					ns = append(ns, n)
 				}
 
-				resp := response.Response{
+				msg := message.Message{
 					Ok:               tc2.ok,
 					MonitorMessages:  monitorMessages,
 					NotifierMessages: tc1.notifierMessages,
 				}
-				notifier.SendResponseAll(context.Background(), mockPP, ns, resp)
+				notifier.SendMessageAll(context.Background(), mockPP, ns, msg)
 			})
 		}
 	}
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index c3377d6..86842ac 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -13,9 +13,9 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/response"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 	"github.com/favonia/cloudflare-ddns/internal/updater"
 )
@@ -123,7 +123,7 @@ func TestUpdateIPsMultiple(t *testing.T) {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
 			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, response.Response{
+			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,
 				MonitorMessages:  tc.monitorMessages,
@@ -215,7 +215,7 @@ func TestDeleteIPsMultiple(t *testing.T) {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
 			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, response.Response{
+			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,
 				MonitorMessages:  tc.monitorMessages,
@@ -296,7 +296,7 @@ func TestUpdateIPsUninitializedProbied(t *testing.T) {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
 			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, response.Response{
+			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,
 				MonitorMessages:  tc.monitorMessages,
@@ -383,7 +383,7 @@ func TestUpdateIPsHints(t *testing.T) {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
 			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, response.Response{
+			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,
 				MonitorMessages:  tc.monitorMessages,
@@ -646,7 +646,7 @@ func TestUpdateIPs(t *testing.T) {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
 			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, response.Response{
+			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,
 				MonitorMessages:  tc.monitorMessages,
@@ -791,7 +791,7 @@ func TestDeleteIPs(t *testing.T) {
 			}
 			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
 
-			require.Equal(t, response.Response{
+			require.Equal(t, message.Message{
 				Ok:               tc.ok,
 				NotifierMessages: tc.notifierMessages,
 				MonitorMessages:  tc.monitorMessages,