Skip to content

feat(botsfw): add SendGate so a platform can refuse a send#80

Merged
trakhimenok merged 1 commit into
mainfrom
feat/send-gate
Jul 15, 2026
Merged

feat(botsfw): add SendGate so a platform can refuse a send#80
trakhimenok merged 1 commit into
mainfrom
feat/send-gate

Conversation

@trakhimenok

Copy link
Copy Markdown
Contributor

Why

bots-fw sends unconditionally, from code a platform cannot intercept. That's safe on Telegram, where a bot may message any chat it knows at any time — but that's a Telegram property, not a universal one.

WhatsApp permits free-form messages only within 24 hours of the recipient's last reply. Outside that window the send fails with error 131047, and only a pre-approved template may be delivered. Meta's documented remediation is exact: "Send the recipient a template message instead."

Today a platform has no way to say "not now". The router would spend an API call to earn a rejection — or worse, deliver a billable template message the app never intended.

This is §1.1 of the platform-neutrality gap analysis, the highest cost-of-delay item found while scoping the WhatsApp port, and the one blocker for bots-fw-whatsapp.

What

An optional interface on WebhookResponder:

type SendGate interface {
	CanSend(c context.Context, m botmsg.MessageFromBot) error
}

A responder that doesn't implement it is treated as always permitting.

This is additive, not breaking — and that's verified

bots-fw-telegram builds and its tests pass unchanged against this branch (checked via a temporary local replace; nothing in that repo was modified). Telegram's responder doesn't implement SendGate, so its behaviour is identical.

Deliberate departure from the gap analysis

The gap analysis proposed getting a refusal into WebhookResponder.SendMessage's return. Reading the actual code, an optional interface is better on two counts:

  1. It doesn't break every existing responder. A signature change would.
  2. It gates before the send, not after. That matters when the attempt itself costs money.

Call sites routed through the seam

Site What it sends
router.go processCommandResponse every command response
router.go the "Unknown Type=%d" message
driver.go the panic handler — up to 3KB of stack trace into the user's chat

The panic-handler send is best-effort: the panic is already logged and reported to analytics, so a refusal there is a warning rather than an error.

Refusals log at Warning, not Error throughout — a gated platform declining an unsolicited message is the system working as designed, not a failure.

Tests

8 new, pinning the things that actually matter:

  • an ungated responder always permits (the compatibility guarantee)
  • a refused send reaches no platform at allSendMessage is never called
  • refusals stay classifiable through wrapping via errors.Is / IsSendNotPermitted

Full suite green; gofmt and go vet clean.

What this does not do

Doesn't touch the other gap-analysis findings — GetBotContext ignoring platformID, the dead BotAPISendMessageOverResponse default, or the integer-ID leaks in botmsg. Those are separate changes.

It also doesn't implement any gate: bots-fw-whatsapp will supply the first real SendGate. This just creates somewhere for it to exist.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SE2shvgkXuR8fAzzMui4zN

bots-fw currently sends unconditionally, from code a platform cannot intercept.
That is safe on Telegram, where a bot may message any chat it knows at any time -
but that is a Telegram property, not a universal one.

WhatsApp permits free-form messages only within 24 hours of the recipient's last
reply. Outside that window a send fails with error 131047, and only a pre-approved
template may be delivered. Today a platform has no way to say "not now", so the
router would spend an API call to earn a rejection - or worse, deliver a billable
template the app never intended.

SendGate is an OPTIONAL interface on WebhookResponder:

    type SendGate interface {
        CanSend(c context.Context, m botmsg.MessageFromBot) error
    }

A responder that does not implement it is treated as always permitting, so this
is additive rather than breaking. Verified: bots-fw-telegram builds and its tests
pass unchanged against this commit (via a temporary local replace).

This deliberately does NOT change WebhookResponder.SendMessage's signature, which
was the original proposal in the gap analysis. An optional interface achieves the
same refusal without breaking every existing responder, and gates BEFORE the send
rather than reporting after it - which matters when the attempt itself costs money.

Routed the three unconditional send sites through the seam:
- router.go processCommandResponse - every command response
- router.go - the "Unknown Type=%d" message
- driver.go - the panic handler, which pushes up to 3KB of stack trace into the
  user's chat. Best-effort: the panic is already logged and sent to analytics, so
  a refusal here is a warning, not an error.

Refusals log at Warning, not Error: a gated platform declining an unsolicited
message is the system working as designed, not a failure.

Tests: 8 new, covering the compatibility guarantee (ungated responders always
permit), that a refused send reaches no platform at all, and that refusals stay
classifiable through wrapping via errors.Is.

Background: spec/research/bots-fw-platform-neutrality-gap-analysis.md §1.1 in
bots-go-framework/backstage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@trakhimenok
trakhimenok merged commit 21cf952 into main Jul 15, 2026
8 checks passed
@trakhimenok
trakhimenok deleted the feat/send-gate branch July 15, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant