81 Commits

Author SHA1 Message Date
Yudhi Armyndharis 09f9c6ac9a chore(sdk): release 0.5.0 across all five SDKs 2026-08-20 15:37:09 +07:00
Yudhi Armyndharis 1437fa969f feat(message): widen mentions to every text-carrying route
A caller could tag participants on send-text and the media sends, but the
same array was rejected with "property mentions should not exist" on the
routes that carry text just as plainly: reply, edit, send-template and the
bulk items. The engines can carry the tags on all of them, so the field was
missing from the DTOs rather than from the engines.

- replyToMessage and editMessage take an optional mentions list, forwarded
  by both adapters. Baileys spreads the existing withMentions helper into
  the content; whatsapp-web.js passes the options bag its library already
  accepts, and omits it entirely when no tags were asked for so an untagged
  send keeps its previous call shape.
- send-template dispatches through sendText, so it also gained linkPreview.
  quotedMessageId stays out: docs/06 publishes this route as one that
  rejects it.
- Every bulk item type forwards its own list, audio included. Audio carries
  no caption, but a mention still tags through contextInfo, which is why
  the single-send audio route accepts it.
- An edit REPLACES the message content, so tags are re-applied rather than
  preserved: a rewritten body loses the tags unless the list is sent again.
- The mentions caps and the 4096 text cap move to shared constants; the
  effective limits are unchanged.

Verified: adapter, service and DTO tests per route, each with a negative
control; the DTO cases run through the real production validation pipe, so
they exercise the whitelist that produced the 400. Plugin rewrites of the
list are asserted on reply and edit, because message:sending is a
moderation chokepoint and reading the caller's own body there would send
the unredacted tags.
2026-08-20 12:06:38 +07:00
Yudhi Armyndharis 1744dd5515 fix(sdk): give subscribe-presence its own request type
`SubscribePresenceDto` had no contract-shape coverage at all. Four clients
declared one `MarkChatRequest` for both `subscribePresence` and
`markUnread`, and the gate mapped that type to `MarkChatUnreadDto`, so the
pair being compared was not the pair that exists. The two DTOs are
identical today, which is the only reason nothing failed; a field added to
either would have gone unnoticed on every typed client.

Each of the four typed clients now declares `SubscribePresenceRequest`,
the gate maps it to `SubscribePresenceDto`, and the per-client coverage
floors rise by one so the pair cannot be dropped again in silence.
Comparisons go from 320 to 324.

BREAKING for the Go and Java clients: `SubscribePresence` takes the new
type. The wire body is unchanged.
2026-08-20 00:36:29 +07:00
m7fz7 f5820a6c93 fix(sdk): split the mark-read body in the other four clients
Rebasing on main brought the shape gate's request-body coverage to the
Python, Go and Java clients, each of which maps MarkChatRequest to
MarkChatReadDto. That pair now fails the same way the JavaScript one did:
the contract carries messageIds and the hand-written type does not.

Each client gets a MarkChatReadRequest carrying the optional list, with
MarkChatRequest left to markUnread and subscribePresence, which take the
chat id alone. The gate maps both pairs for all four clients and each
coverage floor rises by one.
2026-08-19 13:58:13 +04:00
Yudhi Armyndharis f1d9fac1b3 fix(sdk): gate the request bodies every client sends
The shape gate mapped response payloads on all five clients but request bodies
on only two, so a body type that disagreed with the contract was caught on the
JavaScript SDK and the dashboard and nowhere else. Mapping the other three
surfaced 33 drifting pairs.

- Python: 19 request types declared a field optional that the server requires,
  so a body missing chatId or text type-checked and then failed at the API.
  UpdateWebhookRequest no longer derives from the create type, whose url is
  required only on create.
- Go gains six request enums and Java three, replacing plain strings and
  numbers for the proxy scheme, call kind, membership method, chat state, pin
  window and status font.
- WebhookResponseDto declares the event vocabulary it returns instead of a bare
  string array, which is what every client already models.

The gate itself was reading less than it claimed. Numeric Literal and const
block members went unread, so the field fell through to a non-simple token that
the comparison skips: an enum could be edited to anything and stay green. Java
enum constants were never harvested at all, a vocabulary carried as a list was
never compared on any client, and a package-qualified generic resolved to its
raw text, which is how the mentions list on two request records went uncompared.
Each is mutation-tested, and the header now states what is gated and what is
not: Java numeric enums stay Integer because Gson serializes a constant by name.

Six pairs remain excluded with a recorded reason rather than silently skipped.
2026-08-19 07:52:08 +07:00
Yudhi Armyndharis aaeb5bb5af fix(sdk): declare media mentions in Go/Python/Java and gate numeric enums
The request-type gate expansion left two gaps its own review found:

mentions on media/audio sends existed only in the JS client's types -
Go and Python declared it for text sends only, Java for text only
too, so a Go/Python/Java caller could not type-safely @mention on a
media send even though the DTO accepts it (and the merged PR's claim
that the others already had it was wrong). Add the field to all three
(SendAudioRequest inherits from SendMediaRequest in each).

The font-union adjudication passed the gate vacuously: numeric unions
never matched the quoted-literal enum path, and isSimpleToken excluded
union(...) tokens, so ANY numeric union compared equal. The token
builders now recognize unquoted numeric literals and both sides sort
enum members numerically when every member is numeric - renaming one
font member fails the gate (verified).

Also adds the missing transport tests for deleteProfilePicture in all
five clients (the SDKs' documented standard: every method asserts the
precise URL, method and body against a mocked transport).
2026-08-17 11:36:39 +07:00
Yudhi Armyndharis 94db9dc4d1 Merge remote-tracking branch 'origin/main' into feat/sdk-message-list-wire-fields
# Conflicts:
#	CHANGELOG.md
2026-08-17 10:15:08 +07:00
Yudhi Armyndharis 1af0bd4da8 fix(sdk): declare the four message-list wire fields every typed client missed
MessageListItemDto publishes seventeen properties; the message-record
types in the JS, Python, Go and Java clients declared thirteen. The
fields reach the wire from the entity the list endpoint returns, so
every typed SDK consumer received data their type could not express:
chatName (the session's resolved chat display name), author (inbound
group author), and mediaPath/mediaMimetype (the archived-media copy
the chat-media archive writes).

Add the four fields to all four clients. Go also gains the
DeliveryStatus enum (direction already had MessageDirection) so the
record's enums match the contract; Python's MessageRecord moves to
explicit NotRequired[Optional[...]] optionality mirroring the DTO
(functional TypedDict because 'from' is a keyword; typing_extensions
becomes a version-marked runtime dep for pre-3.11 where that form
evaluates at import).

check-contract-shapes now maps MessageRecord -> MessageListItemDto in
all four SDKs (floors raised to match) and its Python harvester
understands the functional TypedDict form. Renaming one field fails
the gate naming it (verified); 117 pairs conform.
2026-08-17 10:07:01 +07:00
Yudhi Armyndharis fb344eeacf feat(sdk): expose deleteProfilePicture everywhere and gate SDK verbs on shared paths
The contract publishes DELETE /api/sessions/:id/profile/picture; no
client built it, and the coverage gate could not see the gap: it
harvests PATHS, so the PUT on the same path satisfied it. The README
meanwhile promises everything outside the excluded list is exposed.

Add deleteProfilePicture to the JS, Python, Go, Java and PHP clients
(with their docs/18 and sdk/README rows), and extend
check-sdk-coverage with a verb layer: for every in-scope contract path
published with more than one verb, each SDK must build that path with
every verb the contract declares. The harvester understands each
client's call idiom, including Go's base() helpers and verb-routing
wrapper helpers, and fails naming the client, verb and path. Removing
one client method fails it (verified); 19 multi-verb paths are
checked across all five SDKs.
2026-08-17 09:48:20 +07:00
Yudhi Armyndharis 9e2969a0ed test: gate follow-ups, Logs render smoke, and Java enum wire round-trips
- The shape gate resolves its inputs from the script's own location, not
  process.cwd(), matching the sibling check-* scripts (direct invocation from
  any directory now behaves like npm run).
- The gate header states its two known blind spots explicitly: TypeScript
  type-aliased enums and Java enum-typed record components compare by presence
  and optionality only — Python Literals and Go const enums compare literally.
- .env.example notes that allowlisted hosts must resolve DNS at webhook
  registration time (they are pinned at delivery).
- New Logs page render smoke (bare node --test harness, mirrors Sessions):
  pins that rows whose nullable fields are all null render with fallbacks —
  no raw null in the DOM, no crash — over the post-wire-conformance AuditLog.
- New Java EnumWireTest: Gson round-trips every snake_case wire token for the
  session, batch, presence, restriction, group, message and chat enums in both
  directions — the @SerializedName wiring the new enum types depend on.
2026-08-16 12:02:13 +07:00
Yudhi Armyndharis 1687192798 feat(sdk): extend the wire-shape gate to the Python, Go and Java clients
The shape gate covered the two TypeScript trees; Python, Go and Java each get
a parser for their declaration style (Python TypedDict totals/NotRequired/
aliased Literals with inheritance, Go structs with json tags plus const-block
enums, Java record components with javadoc stripped first) and a mapping set
with the same floors and exclusion discipline. PHP is deliberately out of
scope and documented why: its client returns untyped arrays throughout —
there is no types layer to conform.

Adjudication of every pair against the DTOs conformed all three clients —
113 pairs gated across five clients, the sole exclusion still the dashboard's
deliberate tri-state engineLoaded:

- Python: response TypedDicts move from blanket total=False to per-field
  NotRequired (via a TYPE_CHECKING-only typing_extensions import — annotations
  are lazy, so the runtime dependency set stays httpx-only), Literals for the
  status/kind enums.
- Go: omitempty is recognized as THE optionality marker (a bare pointer is a
  required nullable field; pointer+omitempty is absent-dominant and flagged
  absorbsNull — nil covers both an absent key and an explicit null, which Go
  cannot spell distinctly); required fields drop their omitempty; eleven
  defined enum types with const blocks replace plain strings; GroupInfo gains
  announce/ephemeralSeconds/locked/memberAddMode. The measurement surfaced two
  genuine wire bugs, both fixed: WebhookResponse.Events and
  ChatHistoryMessage.MentionedIds were single strings where the wire carries
  arrays.
- Java: javadoc between record components no longer drops fields from the
  parse; seven enums (restriction kind, batch message/lifecycle status,
  message type, chat kind, presence state, member-add mode) follow the
  existing SessionStatus pattern; GroupInfo gains its four missing components.

Comparator regressions found and pinned by new spec cases (12 total): const
blocks mis-parsed under gofmt's multi-space padding; javadoc broke Java
component splitting; multi-line Python Literals truncated; pointer null arms
double-appended. Per-client floors: Python 22, Go 24, Java 24.
2026-08-16 10:34:02 +07:00
Yudhi Armyndharis d1900772a0 chore(sdk): release 0.4.0 across all five SDKs
This cycle removed sendCatalog from every SDK (the route is gone — no
engine could fulfil it), so the surface breaks for code that called it.
Under strict 0.x SemVer a break is a minor: 0.3.0 becomes 0.4.0 rather
than 0.3.1. All five move together; none sat out a generation.

Go's DefaultUserAgent carries the version and drifts silently if only
the tag moves, so it is bumped in the same commit; the JavaScript
lockfile and the PHP branch-alias follow their manifests.
2026-08-15 16:30:51 +07:00
Yudhi Armyndharis 4c173ef89e docs(api): document the stop endpoint's incomplete-teardown 502
Mirror the logout endpoint's 502 contract documentation on stop: when both
the graceful disconnect and the force-destroy escalation fail, the session
settles to disconnected locally but the route answers 502 with
SESSION_STOP_INCOMPLETE (retryable, no success audit). Add the
@ApiResponse, the OpenAPI entry, the API reference errors line, and the
matching SDK docblocks.
2026-08-14 19:20:49 +07:00
Yudhi Armyndharis 89966a52bc feat(contract): remove the send-catalog route no engine can fulfil
POST /sessions/:sessionId/messages/send-catalog answered 501 on every
engine — neither library has a catalog-share message type — so the route
could never succeed and only forced all five SDKs to ship a dead method.
The catalog reads, send-product and the catalog service are unchanged;
the engine capability stays documented in docs/29 with no REST exposure.
2026-08-14 09:10:06 +07:00
Yudhi Armyndharis ba9fc29219 docs: correct the claims this branch left inaccurate
Contract, in the published snapshot:
- import-data's 400 enumerated its causes and omitted the two this branch added
  (a table that is not an array, a row that is not an object).
- GET and DELETE group picture named only the id-shape cause, while PUT on the
  same resource already named both — the session-not-active 400 is reachable on
  all three.
- docs/06 documented no 400 at all for GET and DELETE picture.

Code comments that had stopped being true:
- The NODE_ENV justification said every shipped deployment sets it explicitly.
  The compose files do; the image does not — the Dockerfile carries no runtime
  ENV NODE_ENV, so a plain `docker run` takes the permissive branch of every
  hardening listed there. That is now stated rather than implied away.
- The restriction-metrics doc described the mirrored value as what the gauge
  reads. It is the fallback: once the store registers a live recount, that is
  what a scrape calls.
- The blocklist in-flight comment credited the status seed, which resolves
  posters sequentially and cannot have two lookups open — the memo bounds that
  loop; the sharing bounds genuinely concurrent readers.

Three SDK clients still told users isBlocked is always false on Baileys, which
this branch changed. JS/TS carries the field without the claim.

The export-size warning compared against the shipped default only, so an operator
who LOWERED STORAGE_IMPORT_MAX_ENTRIES and restores onto this same gateway got no
warning. It now compares against the lower of the two and names both.

CHANGELOG: "naming 10 of 15" was wrong on both numbers — measured with the gate's
own parser, origin/main documented 9 of 16. And the entry describing the release
workflow's missing checkout is removed: that step was added by this branch and
fixed in it, so the defect never shipped and the surviving entry already states
the release path is checked.
2026-08-13 00:12:42 +07:00
Yudhi Armyndharis 3c2b981cd7 ci(sdk): type-check the Python client, and make the gate able to fail
The package ships py.typed, so its annotations are a published contract that a
consumer's own type checker reads — and nothing verified them. pytest cannot:
TypedDict keys are not enforced at runtime, so a request type missing a field
passes every test here while a typed caller is told the field does not exist.

Two configurations that looked correct were green with a TypedDict key deleted.
Checking only openwa/ sees nothing, because the resources forward bodies
untouched and the typed callers live in tests/. Adding tests/ still saw
nothing, because mypy skips the body of any unannotated function and the test
methods carry no annotations. A third blind spot sat in conftest: make_client()
had no return annotation, so it yielded Any and every call chained off it went
unchecked.

Three pre-existing test defects had to go first: an assertion on a None-returning
method, a filter literal that widened away from its TypedDict, and a search call
passing sessionId=None — which the contract does not allow (optional, not
nullable). The None-omission behaviour that last test covered is now asserted
against build_url, which is what implements it.
2026-08-12 12:56:31 +07:00
Yudhi Armyndharis 47bd0611d6 fix(sdk): annotate list returns with typing.List in the Python resources
Each resource class defines a `list` method, so `list[str]` in an annotation
inside that class resolves to the method rather than the builtin. mypy reports
all fifteen sites as "Function ... is not valid as a type".

This is published type information, not an internal detail: pyproject ships
py.typed for openwa and openwa.resources under PEP 561, so the wrong types are
what a consumer's own type checker reads.
2026-08-12 12:55:48 +07:00
Yudhi Armyndharis c4bb638382 feat(sdk): expose quotedMessageId on the send request types
All five clients gain the optional field, so a typed caller can reply with
media, a location, a contact card or a poll. Java's SendContactRequest and
Python's were the two strict shapes: the record takes a new component and its
two back-compatible constructors were widened, and the TypedDict was split into
a required base plus a total=False subclass — the inheritance shape
SendAudioRequest already used — so three required keys stay required.

Every assertion is on the request BODY, never the URL. Go's omitempty and
Gson both drop an unset value, so a type that forgot the field still compiles
and still routes correctly while sending nothing, which is how one client 400s
while four work. Each client also carries a control asserting an ordinary send
emits no quote key at all.

Refs #1271
2026-08-12 12:45:06 +07:00
Yudhi Armyndharis 89e1f6e6de fix(sdk): declare the chat-history fields the endpoint returns
The chat-history route hands back the engine's IncomingMessage array
verbatim, but the four typed SDKs modelled a strict subset of it: contact,
call and ephemeralDuration were missing, so a typed client had to cast to
read three fields the endpoint genuinely emits.

Adds those, plus backgroundColor and font, which the engine interface
declares and the Baileys extended-text mapper sets - unreachable through
this wwjs-only route today, and declared for the same forward-compatibility
reason as the existing WireStatus fields.

Nothing gated this type, which is why it drifted. The JavaScript SDK's
wire-contract assertion now covers it, so tsc fails the build if the SDK
type and the engine interface diverge in either direction.
2026-08-11 22:07:53 +07:00
Yudhi Armyndharis 94847fd0c7 feat(sdk): expose the channel administration routes in all five clients
The gateway has published admins/demote and owner/transfer since v0.15.0 with no
client counterpart. Both were shipped by this project in the same release that
announced them, and neither got an SDK method — the same defect the presence,
blocked-contacts and membership-request families had.

Three facts a caller cannot infer from the signature are in every doc comment:
both need an OPERATOR-level key, the transfer is irreversible once it lands, and
the whatsapp-web.js engine answers 501 for either because neither page function
works there.

There is deliberately no promote counterpart. Neither engine library has one, so
an admin is promoted from the WhatsApp app and demoted here, and a client method
that always failed would be worse than its absence.

Found by harvesting every path the five clients build and subtracting it from
the contract — the direction no gate runs yet.
2026-08-11 11:51:14 +07:00
Yudhi Armyndharis 34f14fa44f Merge pull request #1248 from rmyndharis/feat/sdk-chat-pin-mute
feat(sdk): expose the chat pin and mute routes in all five SDKs
2026-08-11 11:49:34 +07:00
Yudhi Armyndharis 1691606277 feat(sdk): expose the chat pin and mute routes in all five SDKs
POST /sessions/:id/chats/pin and /chats/mute have shipped in the gateway since
v0.15.0 with no client counterpart, so a caller could archive a chat from a
typed client but not pin or mute one.

muteUntil is absolute epoch milliseconds, and null unmutes. Both readings of an
omitted value — unmute now, mute indefinitely — are opposites, which is why the
route requires the field and why each client documents the unit explicitly: a
seconds-scale value is an instant in 1970, so the mute expires immediately while
the request still answers 200 and nothing in the response says otherwise.

Two clients needed work to send that null at all. Gson drops null members, and
the Java client routed only the session-config body through its null-emitting
serializer, so unmute would have left the key out entirely and been rejected —
on Java alone. MuteChatRequest now takes that path too, which is safe for this
type because both of its fields are required. Go carries the same hazard as an
omitempty tag, deliberately absent, with a marshal test pinning the null.
2026-08-11 11:40:24 +07:00
Yudhi Armyndharis a7e1ce32f2 feat(sdk): expose the call-link route in all five clients
The gateway has published POST /sessions/:sessionId/calls/link since v0.15.0
with no client counterpart, so every SDK shipped rejectCall and no way to create
a link.

startTime is absolute epoch milliseconds and required, and every client says so
in its doc comment: whatsapp-web.js generates an event-linked call and has no
notion of no-start-time, so a link for right now carries the current timestamp
rather than an omitted field. A WhatsApp-side failure answers 403 rather than a
success carrying an empty link.

Found by harvesting every contract path and subtracting what the five clients
build, which is the check that does not exist yet — the shipped route gate runs
SDK to contract only.
2026-08-11 11:29:19 +07:00
Yudhi Armyndharis c92a97722f feat(sdk): expose the blocked-contacts route in all five SDKs
GET /api/sessions/{sessionId}/contacts/blocked has shipped in the gateway but was
reachable from none of the SDKs, so callers that could block and unblock a contact
had no way to read back what the account has blocked.

The method is named listBlocked (list_blocked in Python) rather than blocked. It
sits directly beside block and unblock, differs from them by one character, and
returns a bare array of ids instead of acting on a single contact — a name that
close to its neighbours while taking a different argument list is easy to reach
for by mistake, and the mistake would return a plausible-looking value.

No OPERATOR marker in the docs tables: the route carries no @RequireRole, unlike
block and unblock, so a read-level key is enough.
2026-08-11 10:28:39 +07:00
Yudhi Armyndharis f5d263e368 feat(sdk): expose the group membership-request routes in all five clients
The gateway has published list, approve and reject for group join requests
since v0.15.0 with no client counterpart, so a typed client had to drop to a raw
request. The SDK README states the contract they missed: everything the gateway
publishes is exposed apart from a listed set, and groups is not in that set.

Two shapes are worth naming because they are easy to get wrong from the outside.
Omitting the participant list acts on EVERY pending request, so the clients send
an empty body rather than a null participants key, which would read as an
explicit empty selection. And approve/reject answer 200 even when WhatsApp
refuses some requesters, reporting the outcome per participant in results — the
same partial-refusal contract the other group membership writes use.

Only participantId is guaranteed on a listed request; the engine reports
addedById, method and requestedAt when it has them, so every client types them
as optional.

check:sdk-docs named all fifteen documentation sites once the methods existed.
2026-08-11 10:01:02 +07:00
Yudhi Armyndharis cddbcc0c2b feat(sdk): expose the account's own presence route in all five clients
The gateway has published PUT /sessions/:id/presence since v0.15.0 with no
client counterpart, so a typed client had to drop to a raw request for it. The
SDK README states the contract it missed: everything the gateway publishes is
exposed apart from a listed set, and sessions is not in that set.

Named setOnlinePresence after the route's operationId rather than setPresence,
because the chats resource already carries subscribePresence, getPresence and
sendState — those are a chat's presence, this is the account's, and the two
would otherwise read as variants of one thing.

check:sdk-docs found every documentation site on its own once the methods
existed, which is why the coverage tables are part of this commit rather than a
follow-up.
2026-08-11 09:45:49 +07:00
Yudhi Armyndharis d6b8c9a988 fix(sdk): list the group.join_request webhook event
The gateway validates subscriptions against WEBHOOK_EVENTS, publishes 24 events
in the contract, and dispatches group.join_request at runtime. Four SDKs
enumerate 23 and omit it, so a typed client cannot name an event the server will
happily send: the subscription is legal, the delivery happens, and the union,
Literal, constant block and enum all reject the string.

Add it to JavaScript, Python, Go and Java. PHP takes the event as a plain string
and enumerates nothing, so it was never affected.

The Go constant also goes into the test's want map, because that test iterates
the map rather than the constant set — a constant added without an entry there
is green and unchecked.

Add a derived check so the lists cannot silently diverge again. Every one of
these SDK lists is a hand-maintained restatement of the contract with nothing
comparing the two, which is how one event went missing from four of them at
once. It sits in the lint job rather than the SDK workflow: that workflow is
path-filtered and does not list openapi.json, so changing the contract alone
would not run it, and it never runs on a release tag. Verified against the
unfixed SDKs, where it names all four, and against a deliberately broken scan
pattern, where an empty harvest fails rather than passes.
2026-08-10 13:55:22 +07:00
Yudhi Armyndharis cafd22371f feat(messages): serve sent-message media from the row's inline copy
GET /messages/:chatId/:messageId/media served only the chat-media archive,
and the archive is written on the inbound path alone — so media sent by the
account always answered 404, even though the bytes sit inline on the message
row (the REST send persists base64 payloads, and both engines download media
for the own-send echo). The route now falls back to that inline copy when no
archived file is servable, which also keeps an inbound message's media
downloadable after retention purges its archived file.

A URL-based send stores the URL string, not bytes; the fallback reports it
as absent instead of decoding it as base64. The inline mimetype rides the
same inert-mimetype reduction as the archive path.

Fixes #1165
2026-08-09 14:23:43 +07:00
Yudhi Armyndharis dbe8728095 chore(sdk): release 0.3.0 across all five SDKs
This cycle changed types every typed SDK exposes, several of them incompatibly:
group creation returns the summary shape rather than the detail one, the four group
membership writes return a per-participant result array, ContactRecord's pushname
becomes pushName and loses isBusiness, and sendProduct answers with id. Under strict
0.x SemVer a break is a minor, so 0.2.0 becomes 0.3.0 rather than 0.2.1.

Java moves 0.1.1 → 0.3.0 rather than 0.2.0. It sat out the 0.2.0 generation, so its
0.3.0 carries both that cycle and this one; numbering it 0.2.0 would suggest it trails
the others by a release when its surface now matches theirs. Go was aligned the same
way when it left pseudo-versions behind.

Go's DefaultUserAgent carries the version and drifts silently if only the tag moves,
so it is bumped in the same commit. The JavaScript lockfile still recorded 0.1.0 —
stale since before the 0.2.0 cut — and is now synced.

All five suites pass locally: JS 71, Python 70, PHP 69 (217 assertions), Go ok, Java
green.
2026-08-08 15:37:35 +07:00
Yudhi Armyndharis f4c4db3ca7 fix: correct the defects a pre-release review found in this cycle's work
A systematic review of everything since v0.14.5 found four shipping defects and
several smaller ones, all introduced by this cycle. None had reached a release.

The session-config route takes THREE states per field: an absent key leaves the
value unchanged, an explicit null clears it to the default, a value sets it. The
Go and Java SDKs could emit only two. Go's `*int` with `omitempty` omits a nil
pointer rather than writing null; Gson drops nulls by default. So restoring
`maxReconnectAttempts` to unlimited — which the DTO says no in-range number can
express, and which is the reason the route exists — was unreachable through
either. Both now carry explicit `clear*` flags, Go through a MarshalJSON and
Java through a serializer registered for that one type on a null-emitting Gson.
The shared Gson keeps omitting nulls: applying serializeNulls() globally would
turn every unset field of every other body into an explicit null, which this
same route reads as "reset to default" — a worse bug than the one it fixes.

Three response types dropped fields the API sends: the per-participant group
result omitted `message`, the product-send response omitted `timestamp`, and
Python's new 503 class was never exported from the package root, so the
documented import failed for exactly the class that had been added.

The OpenAPI validity pass was added to the export script only. The document is
produced in two places, and a running gateway kept serving one that fails schema
validation while the committed artifact was clean. Both producers now run the
same passes in the same order, held there by a test.

Also: the four path parameters this cycle added were the only ones in the
document without a schema type, and a structural test now covers that; two Go
doc comments and one Java javadoc had been orphaned by insertions; a Python
comment had drifted from the class it describes; the JavaScript SessionConfig
doc claimed two nullable fields where the DTO has one; every SDK error taxonomy
and the design doc's three tables omitted the new 503 class; and the two
exclusion lists that the README asserts "have to agree" disagreed in both
directions.
2026-08-08 14:57:10 +07:00
Yudhi Armyndharis 9d5100440e feat(sdk): expose session config and the webhook diagnostics
Four operations no SDK wrapped, and a README claim that said otherwise.

GET and PATCH /sessions/{id}/config. Changing a running session's config without
re-linking the account is 0.14.5's headline feature, on a resource every SDK
already exposes, and neither route was reachable through any of them. The GET
carries no role requirement at all.

GET /webhooks — the cross-session list, OPERATOR — and
GET /webhooks/delivery-failures — the diagnostic the 0.14.5 runbook points
operators at when a webhook stops arriving, ADMIN. Both absent everywhere.

They are not on any exclusion list. sdk/README.md named a SHORTER set of
excluded modules than docs/18-sdk-design.md does, and then closed with the
unqualified "all user-facing resources are" exposed — false for these four. The
note now states both exclusion sets, says they have to agree, and drops the
absolute.

The delivery-failure response has no published schema, so it is returned
unshaped rather than given an invented type. Its doc comment records the trap
worth knowing: the log holds deliveries that were ATTEMPTED, so one a smart
filter suppressed never appears in it.

The webhook reads take their own query type rather than borrowing the session
one — delivery-failures accepts a sessionId filter that session pagination has
no field for, and sharing the type would have made that unreachable.
2026-08-08 14:04:13 +07:00
Yudhi Armyndharis 3d44b6f929 fix(sdk): correct the group-create shape and expose session pagination
Two mismatches between the SDK surface and the API.

Group create. `POST /groups` answers the group SUMMARY —
`{id, name, participantsCount, isAdmin?, linkedParentJID?}`, which is what the
controller declares. The four typed SDKs returned `GroupInfo`, the DETAIL shape
`get()` answers, whose own docstring says so. `participants`, `description`,
`owner` and `createdAt` were therefore typed as present on a response that never
carries them; a caller reading them got undefined at runtime with no type error
to warn them.

The id — the field anyone actually wants after creating a group — is in both
shapes and always decoded correctly, which is why this stayed hidden.

Session pagination. `GET /api/sessions` accepts `limit` and `offset`. Only the
Go SDK exposed them; JavaScript, Python, Java and PHP sent a bare path, pinning
the caller to the server default. Each now takes the same optional query its
own groups list already took, in that language's existing idiom: an optional
object, an overload, or a defaulted array.

No gateway change. The group-create return type is a breaking change for SDK
callers, and a narrowing one — the fields it removes were never populated.
2026-08-08 14:03:39 +07:00
Yudhi Armyndharis ca18cbf7bb fix(sdk): give the retryable status a type of its own
Every one of the five SDKs classified 401, 403, 404, 409, 429 and 501 into a
dedicated error class and let 503 fall through to the base one. Go typed 400 as
well and still not 503.

That inverts the mapping against usefulness. 501 Not Implemented is permanent —
the active engine cannot do this and never will, so retrying is pointless — and
it had a type everywhere. 503 is the transport failure a caller should retry,
and it had none: a caller wanting to retry had to reach past the typed surface
and read the raw status off the base error.

The timing is what makes it matter now. 0.14.5 turned 503 into the standard
answer for "WhatsApp never confirmed the operation" across the engine surface,
and 47 of the 189 published operations document one. These error classes were
designed when the status barely occurred.

Each SDK gains one class or sentinel and one branch. The doc comment says it is
retryable, and notes that the gateway deliberately leaves the non-idempotent
sends — group create, channel create, media send — unbounded precisely so they
never answer a 503 for a caller to retry into a duplicate.

Tests cover the classification in JavaScript, Python, Java and Go. The Go case
also asserts a 503 does NOT match ErrNotImplemented, since a sentinel that
matched both would tell a caller to give up on the retryable one.
2026-08-08 14:03:01 +07:00
Yudhi Armyndharis 391e84faff fix(sdk): decode the responses the API actually sends
Three shapes the SDKs typed wrongly. The gateway is unchanged; only the client
types move.

Group membership writes. `addParticipants`, `removeParticipants`,
`promoteParticipants` and `demoteParticipants` answer 200 with a per-participant
`results` array — a partial refusal does NOT fail the batch, which the route's
own description says. All four typed SDKs declared `SuccessResult`
(`{success, message}`), so `results` was dropped at decode time and a rejected
member was indistinguishable from a fully applied batch. They now return
`ParticipantsResult`, a superset, so existing reads keep compiling.

Contacts. `ContactRecord` declared `pushname` where both engine adapters emit
`pushName`. Java binds with a case-sensitive default, so that accessor returned
null for every contact ever fetched. It also carried an `isBusiness` the API
does not send, and omitted `isBlocked` and `profilePicUrl`, which it does.
`isBlocked` is documented as best-effort — the Baileys adapter does not track
blocklist state and reports false.

Product messages. `send-product` answers with the sent message's id under `id`;
the SDKs decoded `MessageResponse.messageId`, so the one value the call returns
was unreachable through a typed accessor. It now returns
`ProductMessageResponse`.

No gate compares an SDK response type to the contract — `check:sdk-routes`
covers route existence only, and each suite asserts against a mock it builds
itself — so none of this could have failed anywhere.
2026-08-08 11:28:35 +07:00
Yudhi Armyndharis 319496f27a chore(sdk-py): release 0.2.0
First release through the PyPI trusted-publishing workflow, and the first
upload since 0.1.0 went up manually on 24 June.

Minor rather than patch, on the same evidence as the JavaScript SDK: 45 commits
have touched sdk/python since that upload, 30 of them feat. All additive, but
far past a patch.

Verified locally: 69 tests pass, and the workflow's textual version guard was
dry-run against the real pyproject.toml — py-sdk-v0.2.0 matches.
2026-08-06 11:40:52 +07:00
Yudhi Armyndharis 8a25b34cd9 ci(sdk): publish the Python SDK to PyPI via trusted publishing
rmyndharis-openwa reached PyPI once, manually, and has sat at 0.1.0 since. Add
a tag-triggered workflow matching js-sdk-release.yml.

Authentication is PyPI Trusted Publishing (OIDC): no token in the workflow or
in repository secrets, because PyPI mints a short-lived credential from the
GitHub OIDC token.

The version guard reads pyproject.toml textually rather than with tomllib. The
guard runs before setup-python so it must not depend on an interpreter, and
tomllib would rule out the 3.9 the package still supports — the sibling guards
in the Java and JavaScript releases are textual for the same reason.

Publishing builds on a single interpreter: the wheel is pure Python and carries
requires-python from the metadata, so the build interpreter cannot shape the
artifact, and the 3.9/3.12 compatibility matrix already runs on every push to
main. The suite still re-runs here so the uploaded artifacts are the ones it
passed against.

The pypi.org trusted-publisher entry is a one-time manual step, documented in
sdk/python/README.md -> Releasing.
2026-08-06 10:16:41 +07:00
Yudhi Armyndharis 339659241a fix: deliver the synchronous stop-mark and close the session-lifecycle follow-ups
The stop/delete ownership fence added an awaited DB query ahead of the
lifecycle call, and since SessionOwnershipService is always provided the
await ran on every deployment — widening the pre-initialize retirement race
the previous commit claimed to keep closed single-node. The tearing-down
mark is now set SYNCHRONOUSLY at entry via a markStopping() delegate, before
the fence's query and before the lifecycle's own first await, so an
in-flight start() sees it by the time its INITIALIZING write settles; a mark
left behind by a 409 is harmless and cleared by the next start(). Locked by
a test that proves the mark lands synchronously even when the fence never
resolves (mutation-tested).

Additional follow-ups:
- Boot validation: reject a heartbeat >= half the lease TTL (a missed
  renewal otherwise lands on the expiry instant), a NODE_URL with embedded
  credentials (undici refuses it — a permanent 503 with creds in the DB),
  and validate AUTOMATION_MAX_PER_SESSION (0 = unlimited, in the
  non-negative list).
- Contract: backgroundColor is now sendable from all five SDKs and
  documented (docs/06 send-voice, the interface comment that wrongly said
  'text only'); the garbled JS/Python linkPreview comments and the stale
  interface linkPreview contract are rewritten; StatusResult docs across
  four SDKs mention send-voice; the Java SendVoiceStatusRequest keeps a
  back-compat constructor.
- Autoreply rules gain a per-session cap mirroring the webhook one.
- Docs: docs/29 counts corrected to 100 methods / 178 supported; stop and
  delete document their new 409; docs/04 lists the automation cap; the send
  breaker's lack of streak time-decay is stated honestly; CHANGELOG records
  the Go vote-clear and Java GroupSettings fixes.
- Coverage: asserting tests added for the getNumberId $1 fallback, the
  send-time LID write-back, the automation cap, and the redis-io
  server-before-clients close order (all mutation-tested).
2026-08-05 11:13:57 +07:00
Yudhi Armyndharis 9f323cb244 fix: close the remaining review findings across config, proxy, pacing, automation and the SDKs
Boot validation now rejects what previously failed silently or late: a
SESSION_LEASE_HEARTBEAT_MS that does not fit inside the TTL (renewals land
after the claim lapsed, so peers adopt sessions from a healthy node), a
NODE_URL that is not an absolute http(s) URL (which only surfaced as a 500
on the first forward), and non-positive media-conversion knobs that were
quietly replaced by defaults.

The forwarder builds its target inside the try, so an unusable owner URL is
the 503 the path already documents rather than a 500, and it relays the
owner's Retry-After/X-RateLimit-* headers — a forwarded 429 previously told
the client to back off with no indication of for how long.

Also: automation rules gain a per-session cap mirroring the webhook one (an
inbound message is evaluated against every rule of its session); the
cold-reachout probe recognises a bare phone number, so a known contact
passed without a suffix is no longer charged as a stranger, and the group
tally's comment now states the truth (a restart forgets what was spent);
the Redis adapter closes the server before releasing its clients, ending a
handful of unhandled-rejection ERRORs on every graceful shutdown; a voice
status accepts backgroundColor; the Sessions page shows its full-page
spinner only on the first load, not on every websocket-triggered refetch;
the Go SDK encodes a nil poll-vote options as [] so clearing a vote works
from the zero value; and the Java GroupSettings record regains a
three-component constructor for callers written before memberAddMode.

Documentation follows the code: the lease's clock-sync requirement and the
double-throttle of a forwarded request are stated in docs/13, NODE_ID is no
longer described as unread, the link-preview contract reads the same in the
DTO, docs, CHANGELOG and all five SDKs, and the group/media/messages
responses document the statuses they actually return. Coverage floors for
src/database were ratcheted back up after the migration specs landed.
2026-08-05 09:10:48 +07:00
Yudhi Armyndharis d808eb1960 feat(sdk): expose voice status in all five SDKs
Every user-facing resource is exposed in the SDKs, and status posting is one of
them, so the new endpoint needs the matching method in each language.

The request wraps its media under `audio` and carries no `recipients`-adjacent
caption field, unlike the image and video variants — the type in each language
reflects that rather than copying the video shape, so the absence is visible at
the call site instead of being a silently ignored argument. The Java test asserts
no caption appears in the serialized body.
2026-08-04 09:14:01 +07:00
Yudhi Armyndharis 8881cd0015 feat(sdk): expose media conversion in all five SDKs
The SDK policy excludes the operator-only administration modules and states that
every user-facing resource is exposed — conversion is user-facing, since it is
what a caller runs before sending a voice note.

Each SDK gains a `media` resource with the same three methods and the same
documented reason for using them, so the reader learns why Ogg/Opus matters from
whichever language they arrived in.

Only the field the caller set is sent. The server distinguishes an absent field
from one supplied as null, so serializing both would turn "no URL given" into
"URL given as null" and be rejected; each SDK builds the body accordingly, and
the Java test asserts the omission rather than trusting the serializer's default.
2026-08-04 08:46:09 +07:00
Yudhi Armyndharis a403d6ca30 feat(messages): generate link previews safely, and accept caller-supplied ones
Baileys produced no link previews at all before this. Its generator delegates to
link-preview-js, an optional peer this project does not install, so every message
containing a URL quietly attempted a dynamic import, threw, and was swallowed
with a warning (Utils/messages.js:41-43) before sending without a preview.

Installing that package is the obvious fix and is the wrong one. It carries an
unfixed SSRF advisory — GHSA-4gp8-rjrq-ch6q, CWE-918, "IPv6 and internal loopback
attacks", no patched release for any version — and the URL being fetched comes
from message text, so an attacker chooses the destination. Putting our guard in
front of it would still leave a DNS-rebinding window, because the package
re-resolves the host when it fetches and there is no way to hand it a pinned
lookup.

So the preview is generated here instead, through the SSRF guard this project
already uses, which validates the destination AND pins the connection to the
vetted address — closing that window. No new dependency and no code under
advisory. WEBHOOK_SSRF_PROTECT and SSRF_ALLOWED_HOSTS govern it like every other
outbound fetch, so a deployment that intentionally allows an internal host keeps
that behaviour.

It is passed on the per-send options rather than the socket config, because
Baileys hardcodes its own getUrlInfo in messages-send and spreads the caller's
options last (messages-send.js:1086) — that spread is the only place ours can
win. It is passed on EVERY text send, not only when a preview was requested, so
the vulnerable generator is never reachable.

The narrow parts are deliberate: non-http schemes never reach the fetch layer,
non-document responses are not read, the body is size-capped, and every failure
yields no preview rather than a failed send — a preview is decoration and must
never cost a message. Writing the scheme check surfaced a real hole in my own
first attempt: prefixing https:// onto text that already had a scheme turned
file:///etc/passwd into a parseable https URL that would have been fetched.

customLinkPreview attaches metadata the caller supplies and fetches nothing at
all, so it works for URLs this server cannot reach. Baileys only:
whatsapp-web.js takes a boolean with nowhere to put a title, so it refuses rather
than silently sending a message that looks nothing like what was asked for.
Combining it with linkPreview: false is refused too — those are opposite
requests, and guessing which was meant would send the wrong message either way.
2026-08-04 07:19:41 +07:00
Yudhi Armyndharis 0cc0f47f5d feat(messages): let callers suppress the link preview on send-text
Adds an optional `linkPreview` to send-text, threaded through both adapters, all
five SDKs and the MCP tool.

It is documented as guaranteed only in its SUPPRESSING direction, because that is
all the two engines actually agree on. Sending false stops the preview on both.
Left unset it means whatever the engine does by default, and the defaults are
genuinely different: whatsapp-web.js asks WhatsApp Web to build a preview
in-page, while Baileys builds none at all — its generator is an optional package
this project does not install. Advertising `true` as "show a preview" would be a
promise Baileys cannot keep, so the parameter says what it does instead.

Each engine expresses the suppression in its own vocabulary rather than through a
shared shim. whatsapp-web.js reads the flag as `linkPreview === false ? undefined
: true` (Client.js:1458), so only an explicit false is forwarded — passing true
would be indistinguishable from passing nothing while adding an options object to
every plain send. Baileys wants `linkPreview: null`, and only when the key is
ABSENT does it call its generator, so suppressing also skips that call.

Which matters more than it sounds: Baileys wires that generator by default
(messages-send.js:1071), so today every message containing a URL dynamically
imports a package that is not installed, throws, and is swallowed with a warn
(Utils/messages.js:41-43) before sending without a preview. Passing false avoids
the round trip entirely.

Existing sends are byte-identical. A send with no preview choice keeps its exact
previous call shape — two arguments plain, three with mentions — rather than
gaining a trailing undefined, which would have been harmless to the engines but
would have rewritten the call shape every existing send spec asserts.

whatsapp-web.js's own docs say the flag "has no effect on multi-device accounts".
Its code plainly acts on it, but that caveat is upstream's to make and is
repeated in the API docs rather than contradicted.
2026-08-03 23:04:50 +07:00
Yudhi Armyndharis 349fd3f1b1 feat(groups): preview a group from its invite code before joining
Adds GET /sessions/:id/groups/join-info?code=…, supported on both engines and
exposed in all five SDKs. Read-only: nothing about the account's membership
changes, which is what makes it safe to call on a code from an untrusted source
and what makes it the step worth taking before POST /groups/join.

The response is its own shape rather than the group info returned to members. A
non-member has no participant list — WhatsApp discloses at most a count — and
reusing the member-facing shape would have forced an empty participants array
that reads as "this group has no members", which is a different and wrong claim.

Only id and name are guaranteed. whatsapp-web.js types getInviteInfo as
Promise<object> and forwards whatever WA Web's queryGroupInvite returns, so
there is no contract to lean on: every other field is read defensively and
omitted when absent rather than defaulted. A createdAt of 0 would assert the
group was created at the epoch, which is worse than saying nothing. A response
carrying no group id at all means the invite was refused — invalid, expired or
revoked — so it surfaces as a not-found rather than a half-populated success.

Baileys returns typed GroupMetadata, so its mapping is direct; the participant
list it does include is dropped, because a preview should not disclose more about
an unjoined group on one engine than on the other.

The route is declared above GET /groups/:groupId deliberately. Nest matches in
declaration order, so a literal segment declared after a parameter route on the
same shape is unreachable — join-info would arrive as a group id and be looked up
as one. Both routes keep working in isolation either way, which is exactly why
that ordering is pinned by a test that reads the decorators back off the class.
2026-08-03 22:54:03 +07:00
Yudhi Armyndharis 3020852217 feat(calls): publish what happened to a ringing call
Adds call.accepted, call.rejected and call.missed as webhook and socket events,
correlated to the preceding call.received by callId, and modelled in the
JavaScript, Python, Go and Java SDKs.

Baileys only. whatsapp-web.js hooks the insert into WhatsApp Web's internal call
collection (Client.js:1084) and receives no status field at all — no accept, no
reject, no removal hook — so it can announce a ring and never its outcome. There
is nothing to wire, rather than something to gate.

An outcome takes its own path through the adapter and returns before the
incoming-call handling. That ordering is the point: a declined call falling into
the offer path would be published as a fresh ring and, with auto-reject enabled,
answered as one.

Two things are reported honestly instead of guessed. The engines say what
happened but not who did it — an accept can arrive from any of the account's
linked devices — so nothing is attributed to anyone. And WhatsApp's `terminate`
is deliberately unmapped: it covers both a caller hanging up before the call was
answered and either side ending an answered one, with nothing in the event to
separate them, so publishing it as an outcome would be wrong about half the time.
Telling those apart needs call-duration tracking, which is its own work.

Offline-replayed signalling is dropped, the same rule the offer path already
applies: WhatsApp resends the traffic for calls that ended while a session was
disconnected, and a reconnect must not announce last week's declined call as
though it had just happened. An outcome for a call this session never saw ring is
dropped too — it belongs to another device's conversation, and carries no caller
identity worth publishing.

The cached live-call handle is released when the call ends rather than left to
expire, so a reject arriving afterwards reports not-found instead of acting on a
dead call.

Three event names rather than one carrying an outcome field, so a consumer that
only cares about missed calls can subscribe to exactly that — and three gateway
emitters rather than one taking the name as a parameter, because the catalog
drift guard discovers emitters by reflection and a parameterised name would leave
the event catalog unverifiable.
2026-08-03 22:35:52 +07:00
Yudhi Armyndharis d5078c24f5 feat(channels): create, delete and mute channels
Adds POST /sessions/:id/channels, POST .../channels/:channelId/delete and
POST .../channels/:channelId/mute, with matching methods in all five SDKs.

Both engines support all three. The enhancement notes had this down as a Baileys
feature with whatsapp-web.js mostly answering 501; reading the library showed
otherwise — Client.createChannel and Client.deleteChannel exist, and mute lives
on the Channel model, which getChatById returns for a @newsletter id. So this
ships at full parity rather than with a gate that was never needed.

Deletion is POST .../channels/:channelId/delete rather than DELETE
.../channels/:channelId, because that route already exists and means
unsubscribe. Leaving a channel and destroying it for every subscriber are very
different acts, and one mistyped verb should not be able to turn the first into
the second. The explicit-path form matches POST .../messages/delete and
POST .../chats/delete already in use here.

whatsapp-web.js reports these failures by returning them rather than raising, and
all three would otherwise read as success:

  createChannel resolves the STRING 'CreateChannelError: …' when creation is
  disabled for the account or the server refuses. Unguarded, that string is
  truthy and would have been mapped into a Channel whose every field is
  undefined, then reported as a successful creation.

  deleteChannel resolves false when the channel is missing or the account does
  not own it, and Channel.mute()/unmute() do the same when refused.

Each becomes an EngineRefusedError, and a mute against an id that is not a
channel at all becomes a not-found rather than a refusal — it is a wrong-id
mistake, not a rejected operation.

The created channel carries its invite CODE, not the full
https://whatsapp.com/channel/… link the library hands back, because the code is
what the subscribe route accepts.
2026-08-03 22:10:18 +07:00
Yudhi Armyndharis c7ebfc0dea feat(labels): create, update and delete labels; list a label's chats
Adds PUT and DELETE /sessions/:id/labels/:labelId and
GET /sessions/:id/labels/:labelId/chats, with matching methods in all five SDKs.

The two engines split down the middle here and neither covers both halves, so
this reports the split rather than papering over it. whatsapp-web.js can read
labels and assign them but cannot edit one — index.d.ts exposes getLabels,
getLabelById, getChatLabels, getChatsByLabelId and addOrRemoveLabels, and nothing
that touches the label itself — so the edits answer 501 there. Baileys is the
mirror image: addLabel/addChatLabel/removeChatLabel and no label query of any
kind, so listing a label's chats answers 501 on it. Assignment is the only part
that works everywhere. Both refusals are declared inline on their adapters so the
parity gate can verify the matrix rows.

PUT /labels/:labelId, not POST /labels, because the caller chooses the id.
WhatsApp carries one app-state write indexed by ['label_edit', id], so create and
update are the same operation and there is no server-assigned id to return —
reusing an existing id rewrites that label rather than failing. A POST would
imply a create-only guarantee the protocol cannot keep.

The jid Baileys' addLabel asks for is genuinely unused on this patch:
chatModifyToPatch builds the index from the label id and never reads it. The
account's own id is passed because the signature demands one, and the comment
says so rather than leaving the next reader to wonder what chat is being
addressed.

`color` is WhatsApp's colour INDEX (0-19), not the hexColor the read routes
return, and the two deliberately do not round-trip: whatsapp-web.js passes hex
straight through from the WA Web store and Baileys only ever speaks in indices,
so a translation table here would be guesswork that silently sets the wrong
colour. Colour 0 is a real colour, which is why nothing on this path may test it
for truthiness — pinned by a test.

Labels have no MCP surface at all today, not even the existing reads, so adding
write-only tools would have been worse than none. That gap is worth closing on
its own terms.
2026-08-03 21:32:46 +07:00
Yudhi Armyndharis c616bf2a6b feat(sessions): observe chat presence — who is online and who is typing
Adds POST /sessions/:id/presence/subscribe and GET /sessions/:id/presence/:chatId,
the presence.update webhook and socket event, five SDK bindings and two MCP
tools.

Baileys only, and the refusal on the other side is honest rather than silent:
whatsapp-web.js exposes sendPresenceAvailable/sendPresenceUnavailable, which
publish the ACCOUNT's own presence, and emits no presence event at all. It
answers 501. That throw is declared inline on the adapter class rather than in a
delegate, because the parity gate reads method bodies off the prototype — a
delegated throw is invisible to it, and the not-available matrix row would have
gone unverified. Most of the wwjs column has that problem already; this method
does not add to it.

Two properties of the underlying protocol shape the API and are documented
instead of papered over. The subscription belongs to the CONNECTION: it does not
survive a restart or an automatic reconnect and must be re-issued. The gateway
deliberately does not replay subscriptions on reconnect — that would report
presence for chats the caller never asked about in the new connection. And
presence is push-only; neither library can query it. So GET serves a remembered
report rather than fetching one, and answers null when nothing has been reported
yet — the chat was never subscribed, or nothing has changed since. That is a
normal state, not a missing resource, so it is 200 with a null body.

Only genuine changes are dispatched onward. WhatsApp emits an update on every
transition and repeats itself freely; one watched chat with an active typist
would otherwise bury every other event a consumer subscribes to. lastSeen and the
group online count drift continuously while nothing observable happens, so they
do not count as changes — treating them as news would defeat the suppression
entirely. Participant ORDER does not count either: the engines build the list by
iterating an object, so a reordering is not a presence change.

The last report is held in memory, per session, bounded, and cleared whenever the
engine restarts. Presence is short-lived by nature: serving "typing" from before
a restart would be worse than serving nothing, and the subscription that produced
it is gone anyway.

lastSeen is absent far more often than not, because WhatsApp withholds it
whenever the contact's privacy settings do. That is the default for most accounts
and is passed through as absent rather than substituted with a guess. An unknown
presence state coming from the library is dropped rather than published, since it
would otherwise land in a public webhook payload as if this gateway understood it.
2026-08-03 21:14:08 +07:00
Yudhi Armyndharis 054f23363b feat(sessions): surface WhatsApp-imposed account restrictions
An account that WhatsApp has restricted was indistinguishable, through this
gateway, from one that simply dropped its connection. Both engines already knew
better and we were discarding it.

whatsapp-web.js reports account standing only as a WAState string on its generic
`disconnected` event, so a Terms-of-Service block arrived as an opaque engine
token alongside every unlink and timeout, and the session went on retrying it
forever. Baileys models the restriction properly — a typed reachoutTimeLock
state, pushed on both onset and lift — but nothing subscribed to it, and the
narrow type on our connection.update handler made it structurally invisible.

The adapters now classify what they see and report it through a neutral signal;
the host records it, announces changes, and serves it on the session. Consumers
get `restriction` on every session response, a `session.restriction` webhook,
and an `openwa_sessions_restricted` gauge.

The classification is deliberately narrow. Only TOS_BLOCK, SMB_TOS_BLOCK and
PROXYBLOCK count on whatsapp-web.js: an unlink, a takeover, a stale client
version and a timeout say nothing about the account's standing, and a signal
that fires on those is one nobody can act on. Baileys' 403 close code is
excluded for the same reason — the library never assigns it, and a proxy that
refuses the WebSocket upgrade with HTTP 403 produces an identical code, so
treating it as a ban would mis-attribute a network fault to the account.

`kind` distinguishes what is actually being restricted, because the two engines
observe different things. A reachout timelock leaves the session connected and
existing chats working, blocking only new conversations; a ToS or proxy block
refuses the connection itself. That difference decides when the record clears:
reaching READY disproves a connection-level block, since such a block is exactly
what would have prevented it, but proves nothing about a timelock.

WhatsApp pushes a timelock only when it changes, so a gateway starting while an
account is already restricted would never hear about it. Baileys is therefore
asked outright once per connection — its own auto-refresh on the per-message 463
nack is unreachable in the pinned version, a dead `else if` behind a branch that
always wins. Both engines otherwise repeat themselves, so the store reports only
changes and the webhook carries no repeats.

Detection stays observational: nothing here changes a session's status or its
reconnect behaviour, so a misread cannot take a working session out of service.
The state is in memory, like the last-error reason it sits beside, because both
engines re-establish it within one connection — which makes a column, a
migration and the export/import plumbing all unnecessary.
2026-08-03 21:14:08 +07:00
Yudhi Armyndharis cc5e8570d2 feat(messages): vote on polls (whatsapp-web.js engine)
Adds POST /api/sessions/:sessionId/messages/vote-poll, a votePoll engine
method, and messages.votePoll in all five SDKs. Baileys is an honest 501: it has
no vote-send helper at all, only decryptPollVote for RECEIVING. Sending would
mean hand-building a PollUpdateMessage with HMAC-SHA256 vote encryption keyed by
the poll creation's messageSecret, which is a separate piece of work.

The signature takes option TEXTS rather than option ids, which is a deliberate
departure from how this was originally scoped. whatsapp-web.js matches poll
options by name (Message.js:1027-1031), and nothing in this API surfaces a
stable per-option id — a message of type `poll` carries no option list at all —
so a caller has no way to obtain an id to send. Taking ids would have meant
inventing and exposing an id scheme purely to translate it back to the names the
library actually wants. The consequence is documented rather than hidden: a poll
with two identically-worded options selects both, because the name is the only
handle there is.

Two failure modes are given real answers instead of leaking:

- vote() throws a BARE STRING, not an Error, when the target is not a poll
  creation message. Left alone that escapes as an opaque 500 for what is plainly
  a client mistake, so a string throw becomes a 400 while any real Error
  propagates untouched. The spec for that asserts the error TYPE rather than its
  message, since a wrapped error would still contain the original text and a
  message match would not notice the downgrade.
- The poll must be inside the engine's 100-message window for the chat, the same
  limit react/delete/edit/pin already carry, so an older poll is a 404 rather
  than a mysterious failure.

No poll.vote event ships here. On whatsapp-web.js the vote_update event is
first-class but unwired, and on Baileys votes arrive needing decryption against
stored poll-creation secrets — that is event-pipeline work with its own
registration obligations, not part of this endpoint.
2026-08-03 21:14:08 +07:00
Yudhi Armyndharis ba82194d97 feat(groups): read, set and remove a group's picture
Adds GET, PUT and DELETE on /api/sessions/:sessionId/groups/:groupId/picture,
two engine methods, and groups.getPicture / setPicture / deletePicture in all
five SDKs. The PUT body matches the existing profile-picture one.

Only the two writes needed engine methods. The read reuses getProfilePicture:
both adapters pass the id straight to their profile-picture lookup, which
accepts a group JID, so a dedicated getGroupPicture would have been a second
name for the same call.

Both writes address the GROUP, and on each engine there is a sibling call that
addresses the OWN ACCOUNT and would look correct in review while quietly
changing the wrong picture — GroupChat.setPicture vs Client.setProfilePicture on
whatsapp-web.js, and updateProfilePicture(groupJid) vs the self-JID form on
Baileys. Specs assert the group-targeted call is the one made, and that the
own-account one is not.

whatsapp-web.js resolves false rather than throwing when the account is not an
admin, so that is mapped to a refusal, matching the other group setters.
2026-08-03 21:14:08 +07:00