Skip to content

v0.2.0

Latest

Choose a tag to compare

@github-actions github-actions released this 02 Sep 12:52
· 1 commit to master since this release
507511a

Installation

go get github.com/rekurt/ymsdk@v0.2.0

Changes

Added

  • Complete API coverage: all 28 documented Bot API endpoints. New in this
    release — messages/pin, unpin, sendReaction, getReactions,
    sendSticker, sendSystemMessage, sendTyping, shareFile, shareImage,
    shareGallery, chats/get, chats/getChat, chats/getMembers, self/get
  • Message editing via EditText and SendMessageOptions.MessageID, plus the
    previously missing reply_quote, forwards and action_buttons parameters
  • ym.Target with ChatTarget, LoginTarget and UserIDTarget, covering the
    user_id recipient form the SDK could not express
  • Automatic payload_id idempotency keys, so a retried send cannot deliver the
    same message twice; opt out with Config.DisableAutoPayloadID
  • updates.Run, which backs off and retries failed polls instead of ending the
    loop, with per-error policies and optional panic recovery
  • updates.NewWebhookHandler: acknowledges within the API's 1s budget,
    processes on a worker pool, and drops the repeats that at-least-once delivery
    guarantees
  • Types the update schema needs: Reaction, ReactionEvent, ReactionsPage,
    ChatMembersUpdate, ChatInfo, ChatMetaData, ChatMember, Forward,
    ActionButtons, BotSettings
  • Text formatting helpers — EscapeMarkdown, Bold, Italic, Strikethrough,
    Underline, Code, CodeBlock, Link
  • Local validation of the documented API limits, reported as *ym.LimitError
  • Retry back-off jitter, a User-Agent identifying the SDK, and
    ym.SleepContext
  • Endpoint path constants in client/ym/endpoints.go
  • LLM skill for using the SDK: skills/ymsdk/, plus AGENTS.md, GEMINI.md,
    Cursor, Copilot and Windsurf adapters
  • messages.SendFileRequest.MimeType — overrides the Content-Type of the
    uploaded document part, replacing files.SendFileOptions.MimeType
  • Full documented parameter coverage for sendText, sendFile, sendImage and
    sendGallery: message_id (edit), reply_message_id, reply_quote,
    forwards, disable_notification, important and action_buttons. Every
    send method now accepts the same shared set instead of a per-method subset.
  • ym.Forward, ym.ActionButtons, ym.ActionButton, ym.ActionButtonIcon and
    the ym.Icon* / ym.ActionButtonIconType constants
  • Send methods now surface the parts of the response they used to drop:
    sendFile returns file_id in Message.Document, sendImage returns
    file_id plus dimensions in Message.Image, and sendGallery returns the
    per-image results in Message.Gallery
  • Client-side enforcement of documented constraints, so an invalid combination
    fails before a request is spent: reply_quote requires reply_message_id,
    forwards cannot be combined with reply_message_id, at most 6 action
    buttons, at most 100 suggest buttons, gallery text at most 6000 characters
  • ym.SuggestButtons now emits the button array in the shape its layout
    requires: a flat array for layout: "false" and a nested one for
    layout: "true". Rows carry no meaning in the flat layout, so they are
    concatenated in order rather than dropped. UnmarshalJSON accepts both
    shapes, so a value survives a round trip through its own output.
  • ym.SuggestLayoutFlat and ym.SuggestLayoutRows constants — the Bot API
    spells the layout as the strings "false" and "true", not as booleans

Removed

  • BREAKING: client/ym/files package and the YMClient.Files field. The
    service parsed a {"ok":true,"message":{...}} response that the Bot API never
    sends — sendFile answers with flat {"ok":true,"message_id":N,"file_id":"..."}
    so files.SendToChat and files.SendToLogin could never succeed against the
    live API. Its tests passed only against a fabricated response shape. The
    package also carried an undocumented caption field that the server silently
    discarded; removing the package supersedes the deprecation from a8c89f8. Use
    messages.SendFile, which now also carries the MimeType override the files
    service provided.

Fixed

  • CI and release builds now use Go 1.25.14 instead of 1.25.3, bringing in the
    latest standard-library security fixes. govulncheck is now blocking in both
    pipelines, so a vulnerable build cannot be published behind a green check.
  • CI and release workflows now use current GitHub Actions releases backed by
    the supported Node.js runtime.
  • Dead fallback lookup in getRequestID. http.Header.Get canonicalises its
    argument, so "X-Request-Id" and "X-Request-ID" address the same entry and
    the second lookup could never find what the first one missed. Collapsed to one
    lookup; behaviour is unchanged. Also clears the canonicalheader lint finding.
  • Retries could duplicate messages. The API documents payload_id as an
    idempotency key; the client never sent one, so a sendText retried after a
    timeout or a 500 delivered the message twice
  • A single incoming image broke the bot. Update.Images was declared as a
    flat []Image while the API sends Image[][] — one list of size variants per
    image. Decoding failed, and since one decode error rejects the whole response,
    every update in that batch was lost; PollLoop then stopped the bot
  • Incoming files were silently dropped. The API field is file; the SDK
    read document, so Update.Document was always nil
  • A single API error killed the bot. PollLoop returned on the first error
    of any kind
  • Cancellation was ignored during back-off. time.Sleep kept a shutting-down
    bot blocked for the full delay, up to MaxBackoff
  • The webhook example could not have worked. It required a header the API
    never sends, decoded a single update where the API sends a batch, and replied
    inline well past the 1s budget
  • Header injection through filenames. sanitizeFilename left CR and LF
    intact, so a filename could inject MIME headers into a multipart part
  • GetFile could return a drained, closed body that read as an empty file
  • A refused webhook delivery was acknowledged and lost. The update was
    recorded in the dedup window before the enqueue succeeded, so the redelivery
    the 503 asked for was skipped as a duplicate and answered 200. Refused
    updates are now rolled back out of the window
  • Shutdown could panic a serving goroutine with send on closed channel
    when it raced an in-flight delivery; new deliveries are now refused before
    the queue is closed
  • GetChat accepted a payload with no id. The absent- and null-payload
    guards did not reach inside the object, so {"data":{}} unmarshalled cleanly
    into a chat with no identity and a nil error
  • SendTyping sent unsupported display values. The check compared against
    the empty string although its own error already said only default and
    text are valid
  • Validation sentinels were scattered across service packages. Sixteen
    public Err… values sat beside the services that returned them, so matching
    one with errors.Is meant importing messages, chats and ym separately.
    They are all in ymerrors now — done before the release, since a published
    sentinel cannot move without breaking imports or keeping an alias forever. A
    source-level test now fails if a sentinel is declared anywhere else
  • A dropped connection lost the delivery. A body that could not be read
    answered 400, which the API treats as final, so an interrupted or truncated
    delivery was never redelivered. It is 503 now; 400 is reserved for a body
    that arrived whole and still would not parse
  • SendTyping sent unsupported indicator types. The type discriminator
    was passed through unchecked, so ym.TypingType("bogus") reached the API as
    the discriminator. Reported by review one field over from the display fix
  • GetMembers sent unsupported role filters. role documents exactly
    three values, and an unrecognised one travelled to the API in the query
    string. Found by auditing every string enum that reaches a request
  • GetUserLink returned links that go nowhere. id, chat_link and
    call_link are all documented as required, but a response omitting them
    produced empty strings and a nil error. Found while auditing the other
    single-object decoders rather than in review
  • A large delivery was truncated and then permanently dropped. The body was
    read through a LimitReader capped at 8 MiB, which truncates silently; the
    remainder failed to parse and answered 400, which the API treats as final. The
    documented maxima reach past that cap — 1000 updates of 6000 characters is
    11 MiB in Cyrillic — so ordinary Russian-language traffic could be lost. The
    cap is now 32 MiB, configurable through MaxBodyBytes, and an oversized body
    answers 503 so the API sends it again
  • A dedup window narrower than a delivery could let updates run twice.
    Admitting a batch evicted ids from that same batch; if it then hit a full
    queue and answered 503, the API redelivered all of it and the evicted prefix
    was processed again. A positive window is now raised to hold a full delivery
  • SendTyping sent processing content with an empty display. The zero value
    of ProcessingContent leaves the discriminator blank, which is the shape a
    caller writes by accident most easily, and the API cannot act on it
  • self/get returned a bot with no identity. A response missing the
    required id produced a zero-valued BotSelf and a nil error. That decoder
    escaped the earlier sweep because it uses a named type rather than an inline
    struct
  • Run ignored Retry-After when retrying a throttled poll. Because the
    client makes a single attempt by default, the 429 surfaced to the loop, which
    then slept its own back-off — one second where the server had asked for sixty,
    prolonging the throttling. The server's instruction now wins, subject to the
    client's rate-limit settings
  • GetAllVoters looped forever on a stalled cursor. A page whose
    cursor.next matched the cursor just sent was appended and the same cursor
    reassigned, so the same votes accumulated without end — the guard the chat and
    member walks already had
  • GetVotersPage could not decode a real response. The documented payload
    sends "cursor": {"next": N}, but PollVotersPage.Cursor was declared as an
    int64, so decoding failed and both it and GetAllVoters were unusable against
    the live API. The existing test passed only because its fixture invented a
    flat cursor. Cursor is now a ym.PollCursor with a Next field
  • GetReactions accepted a response with no reactions_type. Callers must
    branch on it to know which field carries the answer, so an absent
    discriminator is malformed rather than empty. An unfamiliar value is still
    passed through, since the API may add a third shape
  • GetFile still built its path from a literal. The behaviour matched, so
    no test could see it — url.Parse strips the query, leaving the same Path — but
    the endpoint had two definitions free to drift apart. A source-level test now
    fails on any endpoint literal outside endpoints.go
  • SendSystemMessage sent an empty text. The API marks it required and,
    unlike an ordinary send, the endpoint carries no attachment that could give an
    empty body meaning
  • Pin reported success without a message id, which the separate decoder
    there had escaped the guard added to the shared one
  • A send response without message_id was accepted. The endpoints that
    return only that field handed back a Message whose ID was zero and a nil
    error, so a caller could store an unusable id or read a malformed response as
    a success
  • A webhook batch entry without update_id was admitted. Deduplication
    keys on that id alone, so several such entries collapsed into one delivery
    and the rest disappeared behind a 200; the bare-update path already discarded
    the same value
  • {"ok":true,"data":null} slipped past the missing-payload guard. A JSON
    null is four bytes, so the length check passed and Unmarshal left the chat
    zero-valued
  • The examples and recipes only listened for SIGINT. Container supervisors
    send SIGTERM, so routine shutdown killed the process outright and updates
    already acknowledged with 200 were lost before the drain could run
  • A successful poll reset the back-off to a hardcoded second, so a caller
    who set MaxBackoff below that kept it only until the first success and every
    later retry ignored the setting. Both reset sites now go through the same
    helper as the initial value, which is the only place the raw constant is
    reachable from
  • A zero message id passed through the send options was serialised.
    EditText guarded its scalar argument, but SendMessageOptions.MessageID
    and ReplyToMessageID set to a pointer to zero went out as "message_id":0,
    which zero means "no message" everywhere else in the package
  • Action buttons were sent without a title or icon, both of which the API
    marks required, so the request could only be refused
  • GetChat returned an empty result for a response with no data. For a
    single-object query that is a malformed response, not an empty one, and
    returning a zero-valued ChatInfo with a nil error hid schema drift. List
    endpoints keep treating an absent payload as empty
  • ListAll and GetAllMembers returned a repeated page twice. The page was
    appended before the cursor was compared, so the exhaustion case the loop
    exists to handle duplicated its own last page. The cursor is checked first now
  • The webhook example echoed an unescaped filename. Text was escaped but the
    document branch was not, and a filename is chosen by whoever uploaded it, so
    crafted markup rendered as bot-authored content. Every string taken from an
    update is escaped now
  • A slow OnError could spend the webhook's reply budget. Request-path
    errors were reported before the status was written, so a callback doing
    blocking I/O — a remote log write, say — delayed the response past the API's
    one-second limit. The API then saw a timeout instead of the final 4xx or the
    retryable 503 and redelivered something already settled. The response is
    written and flushed first now
  • A concurrent redelivery could be acknowledged with nothing queued.
    The webhook recorded an update id, then tried to enqueue, then rolled the
    record back on failure. In the gap a second delivery of the same update saw
    the record, was answered 200 — final for the API — and the first copy then
    failed and undid the record, so nothing processed the update. Under test 31 of
    64 concurrent callers hit that window. Admission is now one critical section:
    the id is recorded only once a copy is queued
  • The webhook example shared one deadline between the HTTP shutdown and the
    drain
    , so a slow request could consume it and leave the drain no time,
    dropping already-acknowledged updates. Each gets its own now, in the recipe too
  • The legacy updates.Service.Get sent an unvalidated limit. It is a
    separate entry point from GetUpdates and Run in the same file, so it
    bypassed the validation added to them
  • A poll's answer count reported a plain error that also folded in the
    title check, so callers could neither match it with errors.As nor tell
    which of the two was wrong
  • Link could be closed early by a crafted label. Escaping the delimiters
    without escaping the backslash first turned a label ending in one into a
    doubled backslash, which reads as an escaped backslash and leaves the bracket
    after it live: Link("safe\\](https://evil.example)", "https://ok.example")
    rendered a link to the attacker's URL rather than the caller's
  • SendReaction sent reactions with empty identifiers. The API requires
    both type and name, so ym.DefaultReaction("") produced a request that
    could only be rejected; nil still means removal
  • A poll created with PayloadID: ym.Ptr("") sent an empty idempotency key.
    omitempty looks at the pointer rather than the value, so the field went out
    empty and the retry protection was silently absent. An empty string now
    counts as unset, as it already did on the send paths
  • GetVotersPage sent an unvalidated page limit, the only paginated method
    that still did
  • Voters for a poll's first answer were unreachable. The API numbers answers
    from zero, but answer_id == 0 was rejected as a missing value, so the first
    option's voters could never be fetched. Zero is accepted now and only a
    negative index is refused
  • Several documented limits reported ad-hoc errors instead of the promised
    *ym.LimitError, so errors.As worked for some and not others: the shared
    gallery size, the typing timeout and processing-text bounds, and the
    membership caps in UpdateMembers — which also did not say which list was
    over. The typing bounds moved to client/ym/limits.go alongside the rest,
    and a new ym.ValidateRange backs them
  • The webhook recipe registered a URL without its secret. With Secret set
    the handler rejects every delivery that lacks ?secret=, so a bot copied
    from the recipe would have 403'd all legitimate traffic
  • The upload endpoints skipped the documented limits. SendFile,
    SendImage and SendGallery build their own multipart bodies rather than
    going through SendMessageOptions, so an oversized keyboard or an over-long
    gallery caption was serialised and sent; polls.Create did the same with a
    keyboard. All of them now share one validateLimits, and the gallery image
    count reports a *ym.LimitError like every other limit
  • The header-injection fix reached only one of two multipart builders.
    files.SendToChat and files.SendToLogin kept their own sanitizer, which
    escaped quotes and backslashes but left CR and LF intact, so those two calls
    stayed vulnerable. Both builders now share ym.SanitizeFilename
  • A webhook batch that would not decode was acknowledged and dropped.
    The single-update fallback parsed the envelope itself as an empty update, so
    ServeHTTP answered 200, OnError never fired, and every update in the batch
    vanished. Batch payloads now report their decode failure
  • The polling examples overrode the transient-only default with an
    unconditional ActionRetry, teaching exactly the hot loop the default was
    changed to prevent. DefaultPollErrorAction is now exported so a caller who
    only wants to log can delegate the decision
  • The chat length limits were declared but never checked. MaxChatNameLength
    and MaxChatDescriptionLength existed as constants while validateCreate
    looked at neither, so the documented local enforcement did not happen. The
    membership counts in the same function also reported ad-hoc errors rather
    than the promised *ym.LimitError; both now go through ValidateLength and
    ValidateCount
  • The webhook recipe in the skill served an open endpoint. It read the
    secret and the path with optional os.Getenv while the example had already
    been fixed to require them — and the recipe is the copy meant to be pasted
    into other projects. Both values are now mandatory
  • The webhook example echoed unescaped user text, breaking the SDK's own
    rule, and set reply_message_id even when YM_REPLY_CHAT redirected the
    reply to another chat, which the API rejects because the message must belong
    to the target chat
  • An unclassified poll failure spun in a hot loop. The default policy
    stopped only on an enumerated list of permanent API errors and retried
    everything else, so a body that would not decode or a caller's own transport
    failing produced 1250 retries in two seconds under test. The default now
    whitelists what is known to clear on its own — transport failures recognised
    through net.Error, rate limits and 5xx — and stops on the rest
  • ValidatePageLimit returned a plain error while the docs promised
    *ym.LimitError for every documented violation, so page limits could not be
    matched with errors.As like the rest. LimitError also gained a Min field so a
    range violation reads correctly
  • EditText accepted a zero message id and serialised it, although zero
    means "no message" in every other message-scoped method
  • The webhook example could exit before its drain finished. Shutting the
    server down makes ListenAndServe return at once, so main raced the goroutine
    that drains accepted updates — losing exactly the work the early
    acknowledgement promised. The recipe in the skill had the same shape
  • A revoked token looped forever. The default poll policy retried every
    failure, so a permanent 401, 403 or 400 was retried at MaxBackoff
    indefinitely and never reached the caller. The default now retries only what
    a later attempt might survive — network trouble, rate limits, 5xx — and stops
    on permanent failures
  • MaxBackoff did not bound the first retry. The initial delay was a
    hardcoded second, so tuning MaxBackoff down had no effect until the second
    attempt
  • reply_quote without reply_message_id, and forwards combined with a
    reply
    , were serialised and sent even though the API documents both as
    invalid; they now fail locally
  • The share* endpoints sent an undocumented payload_id. They reused the
    send envelope, so every resend carried a key the API documents only for
    sendText, sendSticker, sendSystemMessage and createPoll — risking rejection
    and, worse, making a retry look idempotent when nothing deduplicates it
  • SendTyping accepted a processing indicator with empty or over-long text,
    which the API documents as 1 to 100 characters
  • Run forwarded an out-of-range limit. The resulting 400 fed the default
    retry policy, turning an impossible request into an endless hot loop
  • A transport returning neither a response nor an error panicked the client
    with a nil dereference instead of reporting a transport failure
  • polls.Create sent no idempotency key although createPoll documents
    payload_id, so a retried create could produce two polls
  • GetReactions shipped an out-of-range limit to the server instead of
    reporting a *ym.LimitError locally, unlike every other paginated method
  • Shared images were accepted without dimensions. The API requires width
    and height on shareImage and shareGallery; zeroes produced a request the
    server could only reject
  • The webhook example accepted unauthenticated deliveries. YM_WEBHOOK_SECRET
    was optional while the route was fixed and guessable, so a forged update
    would have made the bot send an authenticated reply to a chat of the
    caller's choosing. The secret is now required
  • The poller example never reached its reaction and membership branches.
    A guard requiring chat and sender ran first, and those updates carry neither
  • ActionRetry on a handler error never retried. It behaved like
    ActionContinue, and the advancing offset then put the update permanently
    out of reach. It now re-invokes the handler on the same update, bounded by
    RunOptions.MaxHandlerRetries
  • self.Update reported KindBadRequest regardless of the actual HTTP status
  • Update was missing reply_to_message, forwarded_messages,
    chat_members_update and reaction

Changed

  • The idempotency guarantee is now stated precisely: payload_id is documented
    for sendText, sendSticker, sendSystemMessage and createPoll only.
    Multipart uploads accept no such key, so retrying one can duplicate it —
    previously the docs implied every retried send was safe
  • DoRequest and DoMultipartRequest now share one implementation; the dupl
    linter threshold is back to its default of 150
  • Endpoint paths are constants — sendFile had been sent both with and without
    a trailing slash from two packages
  • README and CLAUDE.md claimed full API coverage while half the endpoints were
    missing; the claim is now accurate and backed by a per-domain table

Deprecated

  • PollLoop — use updates.Run. The wrapper keeps the stop-on-any-error
    behaviour and now honours context cancellation
  • ValidateRecipient — use ValidateTarget, which understands user_id
  • HttpDoer — use HTTPDoer; the old name remains as a type alias
  • Update.Image and Update.Forward — the API never populated either; read
    Update.Images / OriginalImages() and Update.ForwardedMessages