Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions botsfw/send_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package botsfw

import (
"context"
"errors"
"fmt"

"github.com/bots-go-framework/bots-fw/botmsg"
)

// ErrSendNotPermitted is the base for refusals returned by a SendGate.
//
// Wrap it so callers can classify a refusal with errors.Is without depending on
// a specific platform's package:
//
// fmt.Errorf("outside the 24h window: %w", botsfw.ErrSendNotPermitted)
var ErrSendNotPermitted = errors.New("sending is not permitted right now")

// SendGate is an optional interface a WebhookResponder may implement to refuse
// sends its platform does not currently permit.
//
// It exists because "a bot may message any chat it knows, at any time" is a
// Telegram property, not a universal one. Telegram's responder therefore does
// not implement SendGate, and nothing about its behaviour changes.
//
// Other platforms gate sending. WhatsApp permits free-form messages only within
// 24 hours of the recipient's last reply; outside that window a send fails, and
// only a pre-approved template may be delivered. Without this seam a platform has
// no way to say "not now" — the router would send unconditionally, spending an
// API call to earn a rejection, or worse, delivering a billable template message
// the app never intended.
//
// A responder that does not implement SendGate is treated as always permitting,
// so this is additive: existing responders keep working untouched.
type SendGate interface {
// CanSend reports whether m may be sent right now.
//
// A nil error means the send may proceed. A non-nil error means it may not,
// and describes why; implementations should wrap ErrSendNotPermitted so the
// refusal is classifiable.
//
// CanSend must not perform the send, and should avoid network calls: it is
// consulted on every outbound message.
CanSend(c context.Context, m botmsg.MessageFromBot) error
}

// CanSend reports whether responder permits sending m right now.
//
// Responders that do not implement SendGate always permit, so this is safe to
// call on any responder. Returns nil when the send may proceed.
func CanSend(c context.Context, responder WebhookResponder, m botmsg.MessageFromBot) error {
if responder == nil {
return nil
}
gate, ok := responder.(SendGate)
if !ok {
return nil
}
return gate.CanSend(c, m)
}

// IsSendNotPermitted reports whether err is a SendGate refusal.
func IsSendNotPermitted(err error) bool {
return errors.Is(err, ErrSendNotPermitted)
}

// SendMessageThroughGate consults responder's SendGate, if any, and sends only if
// the send is permitted.
//
// This is the single seam every outbound send should route through, so a platform
// gets one place to refuse rather than one per call site. On refusal it returns a
// zero response and the refusal error, having attempted no send.
func SendMessageThroughGate(
c context.Context,
responder WebhookResponder,
m botmsg.MessageFromBot,
channel botmsg.BotAPISendMessageChannel,
) (OnMessageSentResponse, error) {
if err := CanSend(c, responder, m); err != nil {
return OnMessageSentResponse{}, fmt.Errorf("refused by platform send gate: %w", err)
}
return responder.SendMessage(c, m, channel)
}
136 changes: 136 additions & 0 deletions botsfw/send_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package botsfw

import (
"context"
"errors"
"fmt"
"testing"

"github.com/bots-go-framework/bots-fw/botmsg"
)

// ungatedResponder does not implement SendGate — the Telegram shape.
type ungatedResponder struct {
sent int
}

func (r *ungatedResponder) SendMessage(
_ context.Context, _ botmsg.MessageFromBot, _ botmsg.BotAPISendMessageChannel,
) (OnMessageSentResponse, error) {
r.sent++
return OnMessageSentResponse{}, nil
}

func (r *ungatedResponder) DeleteMessage(_ context.Context, _ string) error { return nil }

// gatedResponder implements SendGate — the WhatsApp shape.
type gatedResponder struct {
ungatedResponder
refuse error
asked int
}

func (r *gatedResponder) CanSend(_ context.Context, _ botmsg.MessageFromBot) error {
r.asked++
return r.refuse
}

var (
_ WebhookResponder = (*ungatedResponder)(nil)
_ WebhookResponder = (*gatedResponder)(nil)
_ SendGate = (*gatedResponder)(nil)
)

// TestCanSend_ungatedResponderAlwaysPermits pins the compatibility guarantee:
// a responder that does not implement SendGate is unaffected by this seam.
func TestCanSend_ungatedResponderAlwaysPermits(t *testing.T) {
if err := CanSend(context.Background(), &ungatedResponder{}, botmsg.MessageFromBot{}); err != nil {
t.Errorf("an ungated responder must always permit, got: %v", err)
}
}

func TestCanSend_nilResponderPermits(t *testing.T) {
if err := CanSend(context.Background(), nil, botmsg.MessageFromBot{}); err != nil {
t.Errorf("a nil responder must not error, got: %v", err)
}
}

func TestCanSend_gatePermits(t *testing.T) {
r := &gatedResponder{}
if err := CanSend(context.Background(), r, botmsg.MessageFromBot{}); err != nil {
t.Errorf("expected permit, got: %v", err)
}
if r.asked != 1 {
t.Errorf("expected the gate to be consulted once, got %d", r.asked)
}
}

func TestCanSend_gateRefuses(t *testing.T) {
want := fmt.Errorf("outside the 24h window: %w", ErrSendNotPermitted)
r := &gatedResponder{refuse: want}

err := CanSend(context.Background(), r, botmsg.MessageFromBot{})
if err == nil {
t.Fatal("expected a refusal")
}
if !IsSendNotPermitted(err) {
t.Errorf("refusal must be classifiable via IsSendNotPermitted, got: %v", err)
}
}

// TestSendMessageThroughGate_refusalSendsNothing is the point of the whole seam:
// a refused send must cost no API call. On WhatsApp an attempted out-of-window
// send earns a rejection, and an attempted template send costs real money.
func TestSendMessageThroughGate_refusalSendsNothing(t *testing.T) {
r := &gatedResponder{refuse: fmt.Errorf("outside the 24h window: %w", ErrSendNotPermitted)}

_, err := SendMessageThroughGate(
context.Background(), r, botmsg.MessageFromBot{}, BotAPISendMessageOverHTTPS,
)
if err == nil {
t.Fatal("expected a refusal")
}
if !IsSendNotPermitted(err) {
t.Errorf("refusal must survive wrapping, got: %v", err)
}
if r.sent != 0 {
t.Errorf("a refused send must not reach the platform, but SendMessage ran %d time(s)", r.sent)
}
}

func TestSendMessageThroughGate_permittedSendProceeds(t *testing.T) {
r := &gatedResponder{}
if _, err := SendMessageThroughGate(
context.Background(), r, botmsg.MessageFromBot{}, BotAPISendMessageOverHTTPS,
); err != nil {
t.Fatalf("expected the send to proceed, got: %v", err)
}
if r.sent != 1 {
t.Errorf("expected exactly 1 send, got %d", r.sent)
}
}

// TestSendMessageThroughGate_ungatedResponderProceeds pins that existing
// responders keep sending exactly as before.
func TestSendMessageThroughGate_ungatedResponderProceeds(t *testing.T) {
r := &ungatedResponder{}
if _, err := SendMessageThroughGate(
context.Background(), r, botmsg.MessageFromBot{}, BotAPISendMessageOverHTTPS,
); err != nil {
t.Fatalf("expected the send to proceed, got: %v", err)
}
if r.sent != 1 {
t.Errorf("expected exactly 1 send, got %d", r.sent)
}
}

// TestIsSendNotPermitted_unrelatedError pins that ordinary send failures are not
// mistaken for refusals.
func TestIsSendNotPermitted_unrelatedError(t *testing.T) {
if IsSendNotPermitted(errors.New("connection reset")) {
t.Error("an unrelated error must not classify as a refusal")
}
if IsSendNotPermitted(nil) {
t.Error("nil must not classify as a refusal")
}
}
13 changes: 11 additions & 2 deletions botswebhook/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,17 @@ func (d webhookDriver) processWebhookInput(
var chatID string
if chatID, err = whc.Input().BotChatID(); err == nil && chatID != "" {
if responder := whc.Responder(); responder != nil {
if _, err = responder.SendMessage(ctx, whc.NewMessage(ErrorIcon+" "+messageText), botsfw.BotAPISendMessageOverResponse); err != nil {
log.Errorf(ctx, fmt.Errorf("failed to report error to user: %w", err).Error())
m := whc.NewMessage(ErrorIcon + " " + messageText)
if _, err = botsfw.SendMessageThroughGate(ctx, responder, m, botsfw.BotAPISendMessageOverResponse); err != nil {
if botsfw.IsSendNotPermitted(err) {
// A gated platform will not accept an unsolicited
// message here. Reporting the panic to the user is
// best-effort; the panic is already logged and sent
// to analytics above.
log.Warningf(ctx, "not reporting error to user: %v", err)
} else {
log.Errorf(ctx, fmt.Errorf("failed to report error to user: %w", err).Error())
}
}
}
}
Expand Down
18 changes: 14 additions & 4 deletions botswebhook/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -778,9 +778,14 @@ func logInputDetails(whc botsfw.WebhookContext, isKnownType bool) {
}

m := whc.NewMessage(fmt.Sprintf("Unknown Type=%d", inputType)) // TODO: Move out of framework to app?
_, err := whc.Responder().SendMessage(c, m, botsfw.BotAPISendMessageOverResponse)
if err != nil {
log.Errorf(c, "Failed to send message: %v", err)
if _, err := botsfw.SendMessageThroughGate(c, whc.Responder(), m, botsfw.BotAPISendMessageOverResponse); err != nil {
if botsfw.IsSendNotPermitted(err) {
// Expected on gated platforms, not a failure: the platform does not
// permit an unsolicited message right now.
log.Warningf(c, "Not reporting unknown input type to the user: %v", err)
} else {
log.Errorf(c, "Failed to send message: %v", err)
}
}
}

Expand All @@ -802,7 +807,12 @@ func (whRouter *webhooksRouter) processCommandResponse(
if responseChannel == "" {
responseChannel = botsfw.BotAPISendMessageOverResponse
}
if _, err = responder.SendMessage(c, m, responseChannel); err != nil {
if gateErr := botsfw.CanSend(c, responder, m); gateErr != nil {
// The platform does not permit this send right now — e.g. WhatsApp outside
// the 24h customer-service window. Skip it rather than spending an API call
// to earn a rejection. Not an error: it is the platform working as designed.
log.Warningf(c, "command response not sent: %v", gateErr)
} else if _, err = responder.SendMessage(c, m, responseChannel); err != nil {
const failedToSendMessageToMessenger = "failed to send a message to messenger"
errText := err.Error()
switch {
Expand Down