package main import ( "bytes" _ "embed" "errors" "fmt" "github.com/goccy/go-json" "go/format" "os" "path/filepath" "regexp" "sort" "strings" "text/template" "github.com/lukaszraczylo/go-telegram/internal/spec" ) // Discriminator-value extractors. The curly form ("always “X”") is // authoritative because Telegram quotes wire literals with curly quotes // throughout the docs; the bare form ("must be X") is the looser // non-quoted variant used for BotCommandScope, InputMedia, etc. var ( discCurlyRE = regexp.MustCompile(`(?:must be|always)\s+“([^”]+)”`) discBareRE = regexp.MustCompile(`must be\s+([A-Za-z0-9_]+)(?:[\s.,]|$)`) ) //go:embed types.tmpl var typesTmpl string //go:embed methods.tmpl var methodsTmpl string //go:embed enums.tmpl var enumsTmpl string //go:embed tests.tmpl var testsTmpl string // runtimeTypes lists types that are intentionally hand-coded and must not be // emitted by the code generator. Skipping them prevents collisions between // generated and hand-coded definitions. var runtimeTypes = map[string]bool{ "InputFile": true, "ResponseParameters": true, "ChatID": true, "MessageOrBool": true, } // fieldTypeOverrides maps "." → Go type expression. // Used for fields whose values are restricted but whose enum the scraper // can't detect (Telegram's curly-quoted emoji literals are routinely // stripped by the scraper's regex due to byte-boundary issues with // multi-byte sequences). The hand-curated typed-string enum lives in // api/enums.go (manual file); this override just retypes the field so // callers get IDE completion and compile-time checks. Generated fields // stay typed even after `make regen`. var fieldTypeOverrides = map[string]string{ "ReactionTypeEmoji.Emoji": "ReactionEmoji", "SendDiceParams.Emoji": "DiceEmoji", } // fieldTypeOverride returns the override type for a (parent, fieldName) // pair, or "" if none. parent is the Go type name owning the field — // either a struct type (e.g. "ReactionTypeEmoji") or a method-params // type (e.g. "SendDiceParams"). func fieldTypeOverride(parent, fieldName string) string { if parent == "" { return "" } return fieldTypeOverrides[parent+"."+fieldName] } // discriminatorSpec describes how to decode a sealed-interface union by // peeking at a single JSON field. type discriminatorSpec struct { Field string // JSON field name to peek at Variants map[string]string // discriminator value → concrete Go type name } // knownDiscriminators is the hand-curated OVERRIDE table for sealed-union // discriminators. deriveDiscriminators computes the same kind of map // automatically from the IR for every union that has one (see // mergeDiscriminators); an entry here always wins the merge regardless of // what derivation would have produced. Use this table only for unions the // generic rule can't handle — e.g. MaybeInaccessibleMessage's integer // discriminator, which needs bespoke decode logic no discriminatorSpec // can express (Field is "" and Variants is nil; the template emits a // hand-coded Unmarshal via isMaybeInaccessibleMessage instead). Every // other entry here exists only because it predates derivation — removing // one and re-running `go test -run TestEmit -update ./cmd/genapi/...` // should reproduce it byte-for-byte from deriveDiscriminators. var knownDiscriminators = map[string]discriminatorSpec{ "ChatMember": { Field: "status", Variants: map[string]string{ "creator": "ChatMemberOwner", "administrator": "ChatMemberAdministrator", "member": "ChatMemberMember", "restricted": "ChatMemberRestricted", "left": "ChatMemberLeft", "kicked": "ChatMemberBanned", }, }, "MessageOrigin": { Field: "type", Variants: map[string]string{ "user": "MessageOriginUser", "hidden_user": "MessageOriginHiddenUser", "chat": "MessageOriginChat", "channel": "MessageOriginChannel", }, }, "ReactionType": { Field: "type", Variants: map[string]string{ "emoji": "ReactionTypeEmoji", "custom_emoji": "ReactionTypeCustomEmoji", "paid": "ReactionTypePaid", }, }, "PaidMedia": { Field: "type", Variants: map[string]string{ "preview": "PaidMediaPreview", "photo": "PaidMediaPhoto", "video": "PaidMediaVideo", }, }, "BackgroundType": { Field: "type", Variants: map[string]string{ "fill": "BackgroundTypeFill", "wallpaper": "BackgroundTypeWallpaper", "pattern": "BackgroundTypePattern", "chat_theme": "BackgroundTypeChatTheme", }, }, "BackgroundFill": { Field: "type", Variants: map[string]string{ "solid": "BackgroundFillSolid", "gradient": "BackgroundFillGradient", "freeform_gradient": "BackgroundFillFreeformGradient", }, }, "ChatBoostSource": { Field: "source", Variants: map[string]string{ "premium": "ChatBoostSourcePremium", "gift_code": "ChatBoostSourceGiftCode", "giveaway": "ChatBoostSourceGiveaway", }, }, "RevenueWithdrawalState": { Field: "type", Variants: map[string]string{ "pending": "RevenueWithdrawalStatePending", "succeeded": "RevenueWithdrawalStateSucceeded", "failed": "RevenueWithdrawalStateFailed", }, }, "TransactionPartner": { Field: "type", Variants: map[string]string{ "fragment": "TransactionPartnerFragment", "user": "TransactionPartnerUser", "telegram_ads": "TransactionPartnerTelegramAds", "telegram_api": "TransactionPartnerTelegramApi", "other": "TransactionPartnerOther", }, }, "MenuButton": { Field: "type", Variants: map[string]string{ "commands": "MenuButtonCommands", "web_app": "MenuButtonWebApp", "default": "MenuButtonDefault", }, }, "OwnedGift": { Field: "type", Variants: map[string]string{ "regular": "OwnedGiftRegular", "unique": "OwnedGiftUnique", }, }, "StoryAreaType": { Field: "type", Variants: map[string]string{ "location": "StoryAreaTypeLocation", "suggested_reaction": "StoryAreaTypeSuggestedReaction", "link": "StoryAreaTypeLink", "weather": "StoryAreaTypeWeather", "unique_gift": "StoryAreaTypeUniqueGift", }, }, // MaybeInaccessibleMessage uses an integer discriminator (date field). // Variants is nil — the standard template block is skipped; a // hand-coded UnmarshalMaybeInaccessibleMessage is emitted instead. "MaybeInaccessibleMessage": { Field: "", Variants: nil, }, } // emitter renders Go source from a spec.API IR. type emitter struct { api *spec.API outDir string enums *enumPlan // variantDiscs maps a concrete variant type name (e.g. // "BotCommandScopeAllPrivateChats") to its discriminator wire-field // + value. Populated once at construction; consulted by the types // template to emit per-variant MarshalJSON that hardcodes the // discriminator so callers don't have to set it by hand. variantDiscs map[string]variantDiscriminator // discs is the merged union-level discriminator table: derivation // output with knownDiscriminators layered on top as overrides (see // mergeDiscriminators). Every codegen path that decides whether a // union auto-decodes — and how — consults this instead of the // package-level knownDiscriminators map, so a union qualifies the // moment deriveDiscriminators can resolve it, hand-curated or not. discs map[string]discriminatorSpec } func newEmitter(api *spec.API, outDir string) *emitter { knownInterfaceTypes = buildUnionTypeSet(api) derived, _ := deriveDiscriminators(api) return &emitter{ api: api, outDir: outDir, enums: planEnums(api), variantDiscs: variantDiscriminators(api), discs: mergeDiscriminators(derived), } } // variantDiscriminator describes the JSON field+value that identifies a // concrete variant of a sealed-interface union on the wire. type variantDiscriminator struct { JSONField string // wire field name, e.g. "type" or "source" GoField string // Go struct field name, e.g. "Type" or "Source" Value string // the wire value, e.g. "all_private_chats" } // variantDiscriminators returns variantTypeName → discriminator for every // concrete struct that participates in a sealed-interface union and has // a string-typed first field whose doc fixes its value (the canonical // "must be X" / "always “X”" patterns Telegram uses). // // Resolution order: // // 1. knownDiscriminators reverse-lookup (the 13 auto-decode unions). // This guarantees parity with UnmarshalXxx dispatch for the unions // that round-trip through the library. // 2. Doc-string analysis of the variant's first field, for marker-only // unions (BotCommandScope, InputMedia, etc.) where the IR has no // explicit discriminator metadata. // // Variants whose first field has no discriminator hint (Message, // InaccessibleMessage, the InputMessageContent family) are omitted — // the caller writes the dispatching fields directly and Telegram // identifies them structurally. func variantDiscriminators(api *spec.API) map[string]variantDiscriminator { out := make(map[string]variantDiscriminator, 128) // Pass 1: reverse-lookup from knownDiscriminators. for _, ds := range knownDiscriminators { if ds.Field == "" { continue } for value, variant := range ds.Variants { out[variant] = variantDiscriminator{ JSONField: ds.Field, Value: value, } } } // Build the set of every variant type referenced by any OneOf so we // can scan only those (avoids matching free-text "must be" prose in // non-variant types like Message). variantSet := make(map[string]bool, 128) for _, t := range api.Types { for _, v := range t.OneOf { variantSet[v] = true } } // Pass 2: doc-parse for variants without a known discriminator. for _, t := range api.Types { if !variantSet[t.Name] { continue } if _, ok := out[t.Name]; ok { // Pass-1 already provided the wire value; we still need // the Go field name (mirrors the JSON field but with // proper case). Resolve from t.Fields by JSONName match. disc := out[t.Name] for _, f := range t.Fields { if f.JSONName == disc.JSONField { disc.GoField = f.Name out[t.Name] = disc break } } continue } disc, ok := extractVariantDiscriminator(t) if !ok { continue } out[t.Name] = disc } // Drop entries we couldn't resolve a Go field for (defensive — every // pass-1 hit should have matched, but better to skip than emit // broken code referencing an unknown field name). for name, d := range out { if d.GoField == "" { delete(out, name) } } return out } // extractVariantDiscriminator inspects the first field of a variant // struct and returns its discriminator if the field is a required // string whose doc nails the value via "must be X" or "always “X”". // Returns (zero, false) when no clear discriminator is present. func extractVariantDiscriminator(t spec.TypeDecl) (variantDiscriminator, bool) { if len(t.Fields) == 0 { return variantDiscriminator{}, false } f := t.Fields[0] if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" { return variantDiscriminator{}, false } value := parseDiscriminatorDoc(f.Doc) if value == "" { return variantDiscriminator{}, false } return variantDiscriminator{ JSONField: f.JSONName, GoField: f.Name, Value: value, }, true } // parseDiscriminatorDoc extracts the wire-level discriminator value // from a field doc string. Handles both Telegram phrasings: // // - "Scope type, must be all_private_chats" (bare token) // - "Type of the message origin, always “user”" (curly-quoted) // // Returns "" when no discriminator is present. func parseDiscriminatorDoc(doc string) string { // Curly-quoted form takes priority: "must be “X”" or "always “X”". if m := discCurlyRE.FindStringSubmatch(doc); len(m) == 2 { return m[1] } // Bare-token form: "must be " terminated by end-of-string, // punctuation, or whitespace. if m := discBareRE.FindStringSubmatch(doc); len(m) == 2 { return m[1] } return "" } // discriminatorFieldCandidates returns the Go field names to try, in // priority order, when looking for a discriminator shared by every // variant of a union: the hand-curated field from knownDiscriminators // (resolved to its Go name via the first variant that declares it) when // the union already has an override entry, then "Type", "Status", // "Source" — the three names Telegram's docs consistently use for this // purpose. Shared by planUnifiedUnionEnums (enum naming, enums.go) and // deriveDiscriminators (decode dispatch, below) so the two never disagree // on where to look. func discriminatorFieldCandidates(unionName string, variants []*spec.TypeDecl) []string { var candidates []string seen := map[string]bool{} add := func(name string) { if name == "" || seen[name] { return } seen[name] = true candidates = append(candidates, name) } if ds, ok := knownDiscriminators[unionName]; ok && ds.Field != "" { for _, v := range variants { for _, f := range v.Fields { if f.JSONName == ds.Field { add(f.Name) break } } } } add("Type") add("Status") add("Source") return candidates } // fieldByGoName returns the field named name on fields, or nil if none matches. func fieldByGoName(fields []spec.Field, name string) *spec.Field { for i := range fields { if fields[i].Name == name { return &fields[i] } } return nil } // discriminatorValue extracts the wire-level value a variant field // contributes to a discriminator: a single-entry EnumValues list (the // scraper's detected value), or a doc-parsed "must be X" / "always “X”" // literal via parseDiscriminatorDoc (the same extraction // extractVariantDiscriminator uses for the first-field case). Returns "" // when the field isn't a required string or carries no resolvable value. func discriminatorValue(f spec.Field) string { if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" { return "" } if len(f.EnumValues) == 1 { return f.EnumValues[0] } return parseDiscriminatorDoc(f.Doc) } // deriveDiscriminators inspects every union TypeDecl (len(OneOf) > 0) in // api and tries to pick a discriminator JSON field automatically, so the // hand-maintained knownDiscriminators map no longer has to name every // union that qualifies. A union qualifies when, for some candidate field // (see discriminatorFieldCandidates), EVERY variant declares that field // and discriminatorValue resolves a non-empty wire value for it, and // those values are unique across variants. // // Returns the derived map plus the names of unions that did not qualify, // in declaration order. Callers merge the derived map with // knownDiscriminators (mergeDiscriminators) and decide what to do about // the leftovers — rescue via a hand-curated override, or fail the build // if the union is reachable from real Telegram data (checkDiscriminatorGate). func deriveDiscriminators(api *spec.API) (map[string]discriminatorSpec, []string) { typeByName := make(map[string]*spec.TypeDecl, len(api.Types)) for i := range api.Types { typeByName[api.Types[i].Name] = &api.Types[i] } derived := make(map[string]discriminatorSpec, 32) var ungated []string for i := range api.Types { u := &api.Types[i] if len(u.OneOf) == 0 { continue } variants := make([]*spec.TypeDecl, 0, len(u.OneOf)) resolved := true for _, vName := range u.OneOf { v, found := typeByName[vName] if !found { resolved = false break } variants = append(variants, v) } if !resolved { ungated = append(ungated, u.Name) continue } ds, qualified := deriveUnionDiscriminator(u.Name, variants) if !qualified { ungated = append(ungated, u.Name) continue } derived[u.Name] = ds } return derived, ungated } // deriveUnionDiscriminator tries each candidate Go field name in // priority order and returns the first one where every variant declares // that field with a resolvable, unique wire value. func deriveUnionDiscriminator(unionName string, variants []*spec.TypeDecl) (discriminatorSpec, bool) { for _, goName := range discriminatorFieldCandidates(unionName, variants) { jsonField := "" values := make(map[string]string, len(variants)) // wire value -> variant Go type name qualifies := true for _, v := range variants { f := fieldByGoName(v.Fields, goName) if f == nil { qualifies = false break } value := discriminatorValue(*f) if value == "" { qualifies = false break } if jsonField == "" { jsonField = f.JSONName } else if f.JSONName != jsonField { qualifies = false break } if _, dup := values[value]; dup { qualifies = false break } values[value] = v.Name } if qualifies { return discriminatorSpec{Field: jsonField, Variants: values}, true } } return discriminatorSpec{}, false } // mergeDiscriminators layers the hand-curated knownDiscriminators table // on top of derived: every entry deriveDiscriminators produces is used // as-is unless knownDiscriminators also names that union, in which case // the hand-curated entry wins outright (the whole discriminatorSpec, not // a per-key merge). func mergeDiscriminators(derived map[string]discriminatorSpec) map[string]discriminatorSpec { merged := make(map[string]discriminatorSpec, len(derived)+len(knownDiscriminators)) for name, ds := range derived { merged[name] = ds } for name, ds := range knownDiscriminators { merged[name] = ds } return merged } // isInboundReachable reports whether Telegram can actually send data // typed as the named union to the bot: as a field on some type that // isn't itself send-only, or as a method's return value. "Input*"-named // unions are excluded outright regardless of where they're referenced — // by Telegram's own naming convention in this API, every such union is // something the bot constructs and marshals, never something it decodes. func isInboundReachable(api *spec.API, unionName string) bool { if strings.HasPrefix(unionName, "Input") { return false } for _, m := range api.Methods { if typeRefIsUnion(m.Returns, api, unionName) { return true } } for _, t := range api.Types { if strings.HasPrefix(t.Name, "Input") { continue // send-only struct; its fields are never decoded either. } for _, f := range t.Fields { if typeRefIsUnion(f.Type, api, unionName) { return true } } } return false } // typeRefIsUnion reports whether tr resolves to the named union: directly, // as an array element, or via an inline oneOf variant-set match against // unionName's own OneOf list. func typeRefIsUnion(tr spec.TypeRef, api *spec.API, unionName string) bool { switch tr.Kind { case spec.KindNamed: return tr.Name == unionName case spec.KindArray: if tr.ElemType != nil { return typeRefIsUnion(*tr.ElemType, api, unionName) } case spec.KindOneOf: for i := range api.Types { if api.Types[i].Name == unionName && matchesVariants(tr.Variants, api.Types[i].OneOf...) { return true } } } return false } // checkDiscriminatorGate fails codegen when a union Telegram can actually // send to the bot has no usable discriminator — neither derived nor // hand-curated. Such a union decodes to a bare interface field that no // JSON codec can ever fill (the RichBlock/RichText Bot API v10 // regression this generalisation fixes: both had 20+ variants and no // knownDiscriminators entry, so genapi silently emitted no UnmarshalXxx // helper at all). Send-only "Input*" unions are exempt — see // isInboundReachable. func checkDiscriminatorGate(api *spec.API) error { _, ungated := deriveDiscriminators(api) typeByName := make(map[string]*spec.TypeDecl, len(api.Types)) for i := range api.Types { typeByName[api.Types[i].Name] = &api.Types[i] } var offending []string for _, name := range ungated { if _, overridden := knownDiscriminators[name]; overridden { continue // hand-curated rescue, e.g. MaybeInaccessibleMessage. } if isInboundReachable(api, name) { offending = append(offending, name) } } if len(offending) == 0 { return nil } var b strings.Builder fmt.Fprintf(&b, "genapi: %d inbound union(s) have no derivable or hand-curated discriminator "+ "and would decode as an unfillable interface field:", len(offending)) for _, name := range offending { fmt.Fprintf(&b, "\n - %s (variants: %s): no single field is present, required, and holds a unique non-empty wire value on every variant", name, strings.Join(typeByName[name].OneOf, ", ")) } return errors.New(b.String()) } // knownInterfaceTypes is the full set of sealed-interface union type names // (both auto-decoded ones in knownDiscriminators and marker-only ones from // types with OneOf). Populated at emitter construction. goType and // unionTypeFor consult this so optional fields of any union type stay // bare interface, never *Interface (which is meaningless in Go and trips // users at every call site). var knownInterfaceTypes = map[string]bool{} // emitTypes renders types.gen.go. func (e *emitter) emitTypes() error { t, err := template.New("types").Funcs(funcsWithDiscs(e.enums, e.variantDiscs, e.discs)).Parse(typesTmpl) if err != nil { return fmt.Errorf("parse types.tmpl: %w", err) } filtered := *e.api filtered.Types = nil for _, typ := range e.api.Types { if !runtimeTypes[typ.Name] { filtered.Types = append(filtered.Types, typ) } } var buf bytes.Buffer if execErr := t.Execute(&buf, &filtered); execErr != nil { return fmt.Errorf("execute types.tmpl: %w", execErr) } src, err := format.Source(buf.Bytes()) if err != nil { // Surface the unformatted output so debugging is possible. return fmt.Errorf("gofmt types.gen.go: %w\n--- unformatted ---\n%s", err, buf.String()) } return os.WriteFile(filepath.Join(e.outDir, "types.gen.go"), src, 0o600) } // loadAPI reads and decodes the IR JSON. func loadAPI(path string) (*spec.API, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var api spec.API if err := json.Unmarshal(data, &api); err != nil { return nil, err } return &api, nil } // funcsWithDiscs returns the shared FuncMap with the variant // discriminator helpers bound to variantDiscs. types.tmpl uses // variantDiscFor/variantHasDisc to emit per-variant MarshalJSON that // hardcodes the wire discriminator value. unionDiscs is the merged // union-level table (see mergeDiscriminators), threaded into funcs. func funcsWithDiscs(plan *enumPlan, variantDiscs map[string]variantDiscriminator, unionDiscs map[string]discriminatorSpec) template.FuncMap { fm := funcs(plan, unionDiscs) fm["variantHasDisc"] = func(name string) bool { _, ok := variantDiscs[name] return ok } fm["variantDiscField"] = func(name string) string { return variantDiscs[name].JSONField } fm["variantDiscGoField"] = func(name string) string { return variantDiscs[name].GoField } fm["variantDiscValue"] = func(name string) string { return variantDiscs[name].Value } return fm } // funcs is the FuncMap shared across templates. plan is the resolved // enum plan; pass nil only in unit tests that don't exercise enums. discs // is the merged union-level discriminator table (see // mergeDiscriminators); pass nil (or knownDiscriminators directly) in // unit tests that only exercise the hand-curated unions. func funcs(plan *enumPlan, discs map[string]discriminatorSpec) template.FuncMap { return template.FuncMap{ "goType": goType, "goField": func(parent string, f spec.Field) string { return goField(plan, parent, f) }, "goFieldP": func(methodName string, f spec.Field) string { return goFieldX(plan, methodEnumParent(methodName), title(methodName)+"Params", f) }, "docComment": docComment, "isOptional": func(f spec.Field) bool { return !f.Required }, "not": func(b bool) bool { return !b }, "title": title, "isFileField": isFileField, "fileCheck": fileCheck, "multipartFieldEntry": func(parent string, f spec.Field) string { return multipartFieldEntry(plan, parent, f) }, "multipartFieldEntryP": func(methodName string, f spec.Field) string { return multipartFieldEntryX(plan, methodEnumParent(methodName), title(methodName)+"Params", f) }, "multipartFileEntry": multipartFileEntry, "returnGoType": returnGoType, // enum helpers "enums": func() []enumDecl { if plan == nil { return nil } return plan.All() }, "enumConstName": constName, // discriminator helpers for types.tmpl — all consult the merged // table (discs), not the package-level knownDiscriminators, so a // derived-only union gates exactly like a hand-curated one. "hasDiscriminator": func(name string) bool { s, ok := discs[name]; return ok && len(s.Variants) > 0 }, "isSealedUnionReturn": func(tr spec.TypeRef) bool { if tr.Kind != spec.KindNamed { return false } s, ok := discs[tr.Name] return ok && len(s.Variants) > 0 }, "isSealedUnionArrayReturn": func(tr spec.TypeRef) bool { if tr.Kind != spec.KindArray || tr.ElemType == nil || tr.ElemType.Kind != spec.KindNamed { return false } s, ok := discs[tr.ElemType.Name] return ok && len(s.Variants) > 0 }, "sealedUnionElemName": func(tr spec.TypeRef) string { if tr.Kind == spec.KindArray && tr.ElemType != nil { return tr.ElemType.Name } return "" }, "isMaybeInaccessibleMessage": func(name string) bool { return name == "MaybeInaccessibleMessage" }, "discriminatorField": func(name string) string { return discs[name].Field }, "discriminatorMap": func(name string) map[string]string { return discs[name].Variants }, // union-field helpers for per-struct UnmarshalJSON emission "unionFields": func(t spec.TypeDecl) []unionField { return unionFieldsOf(t, discs) }, "isArrayUnion": func(tr spec.TypeRef) bool { return hasUnionElem(tr, discs) }, "unionTypeName": func(tr spec.TypeRef) string { name, _ := unionTypeFor(tr, discs); return name }, "unionAlternates": unionAlternates, } } // title upper-cases the first byte of s (ASCII only — all Telegram method names are ASCII). func title(s string) string { if s == "" { return "" } r := s[0] if r >= 'a' && r <= 'z' { r = r - 'a' + 'A' } return string(r) + s[1:] } // isFileField reports whether the field carries an InputFile. func isFileField(f spec.Field) bool { return mentionsInputFileTr(f.Type) } func mentionsInputFileTr(tr spec.TypeRef) bool { switch tr.Kind { case spec.KindNamed: return tr.Name == "InputFile" case spec.KindArray: if tr.ElemType != nil { return mentionsInputFileTr(*tr.ElemType) } case spec.KindOneOf: for _, v := range tr.Variants { if v == "InputFile" { return true } } } return false } // fileCheck returns the HasFile guard line for a file-carrying field. // Both named InputFile and InputFile-or-String oneOf fields are now *InputFile, // so no type assertion is needed in either case. func fileCheck(f spec.Field) string { return fmt.Sprintf("\tif p.%s != nil && p.%s.IsLocalUpload() { return true }\n", f.Name, f.Name) } // multipartFileEntry returns the MultipartFiles append block for a file field. // Both named InputFile and InputFile-or-String oneOf fields are now *InputFile, // so the same code works for both cases. func multipartFileEntry(f spec.Field) string { jsonName := f.JSONName return fmt.Sprintf( "\tif p.%s != nil && p.%s.IsLocalUpload() {\n\t\tname := p.%s.Filename\n\t\tif name == \"\" { name = %q }\n\t\tfiles = append(files, client.MultipartFile{FieldName: %q, Filename: name, Reader: p.%s.Reader})\n\t}\n", f.Name, f.Name, f.Name, jsonName, jsonName, f.Name) } // multipartFieldEntry generates the line that adds f to the multipart map. // Required scalar fields go in unconditionally; optional ones go in only // when non-zero/non-empty. Typed-string enum fields are cast to string // before assignment because the multipart map is map[string]string. func multipartFieldEntry(plan *enumPlan, parent string, f spec.Field) string { return multipartFieldEntryX(plan, parent, parent, f) } // multipartFieldEntryX mirrors goFieldX: enumParent keys the enum plan, // overrideParent keys fieldTypeOverrides. They differ only for method // params. func multipartFieldEntryX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string { enumName := plan.FieldEnum(enumParent, f.Name) if enumName == "" { enumName = fieldTypeOverride(overrideParent, f.Name) } switch f.Type.Kind { case spec.KindPrimitive: switch f.Type.Name { case "int64": if f.Required { return fmt.Sprintf("\tout[%q] = strconv.FormatInt(p.%s, 10)\n", f.JSONName, f.Name) } return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatInt(*p.%s, 10) }\n", f.Name, f.JSONName, f.Name) case "string": if enumName != "" { if f.Required { return fmt.Sprintf("\tout[%q] = string(p.%s)\n", f.JSONName, f.Name) } return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = string(p.%s) }\n", f.Name, f.JSONName, f.Name) } if f.Required { return fmt.Sprintf("\tout[%q] = p.%s\n", f.JSONName, f.Name) } return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = p.%s }\n", f.Name, f.JSONName, f.Name) case "bool": if f.Required { return fmt.Sprintf("\tout[%q] = strconv.FormatBool(p.%s)\n", f.JSONName, f.Name) } return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatBool(*p.%s) }\n", f.Name, f.JSONName, f.Name) case "float64": if f.Required { return fmt.Sprintf("\tout[%q] = strconv.FormatFloat(p.%s, 'f', -1, 64)\n", f.JSONName, f.Name) } return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatFloat(*p.%s, 'f', -1, 64) }\n", f.Name, f.JSONName, f.Name) } case spec.KindOneOf: // Integer-or-String → ChatID: use .String() wire form. if matchesVariants(f.Type.Variants, "int64", "string") { if f.Required { return fmt.Sprintf("\tout[%q] = p.%s.String()\n", f.JSONName, f.Name) } return fmt.Sprintf("\tif !p.%s.IsZero() { out[%q] = p.%s.String() }\n", f.Name, f.JSONName, f.Name) } // InputFile-or-String → *InputFile: non-upload branch sends PathOrID. if matchesVariants(f.Type.Variants, "InputFile", "string") { return fmt.Sprintf("\tif p.%s != nil && !p.%s.IsLocalUpload() && p.%s.PathOrID != \"\" { out[%q] = p.%s.PathOrID }\n", f.Name, f.Name, f.Name, f.JSONName, f.Name) } // Sealed-interface unions — JSON-marshal. if f.Required { return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) }\n", f.Name, f.JSONName) } return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName) } // Named or array: fall back to JSON-marshal to JSON string. if f.Required { return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) }\n", f.Name, f.JSONName) } return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName) } func returnGoType(tr spec.TypeRef) string { switch tr.Kind { case spec.KindPrimitive: return tr.Name case spec.KindNamed: // Sealed-interface unions are returned by interface value, not pointer // (you can't take a pointer to an interface in any useful way; the // generated UnmarshalXxx returns the interface directly). if _, ok := knownDiscriminators[tr.Name]; ok { return tr.Name } // MessageOrBool is a hand-coded runtime wrapper — pointer return. return "*" + tr.Name case spec.KindArray: if tr.ElemType == nil { return "[]any" } return "[]" + returnGoElem(*tr.ElemType) case spec.KindOneOf: // Integer-or-String return (rare but possible). if matchesVariants(tr.Variants, "int64", "string") { return "ChatID" } return "any" } return "any" } func returnGoElem(tr spec.TypeRef) string { switch tr.Kind { case spec.KindPrimitive: return tr.Name case spec.KindNamed: return tr.Name case spec.KindArray: if tr.ElemType == nil { return "any" } return "[]" + returnGoElem(*tr.ElemType) } return "any" } // emitMethods renders methods.gen.go. func (e *emitter) emitMethods() error { t, err := template.New("methods").Funcs(funcs(e.enums, e.discs)).Parse(methodsTmpl) if err != nil { return fmt.Errorf("parse methods.tmpl: %w", err) } var buf bytes.Buffer if execErr := t.Execute(&buf, e.api); execErr != nil { return fmt.Errorf("execute methods.tmpl: %w", execErr) } src, err := format.Source(buf.Bytes()) if err != nil { return fmt.Errorf("gofmt methods.gen.go: %w\n--- unformatted ---\n%s", err, buf.String()) } return os.WriteFile(filepath.Join(e.outDir, "methods.gen.go"), src, 0o600) } // emitEnums renders enums.gen.go. func (e *emitter) emitEnums() error { t, err := template.New("enums").Funcs(funcs(e.enums, e.discs)).Parse(enumsTmpl) if err != nil { return fmt.Errorf("parse enums.tmpl: %w", err) } var buf bytes.Buffer if execErr := t.Execute(&buf, e.api); execErr != nil { return fmt.Errorf("execute enums.tmpl: %w", execErr) } src, err := format.Source(buf.Bytes()) if err != nil { return fmt.Errorf("gofmt enums.gen.go: %w\n--- unformatted ---\n%s", err, buf.String()) } return os.WriteFile(filepath.Join(e.outDir, "enums.gen.go"), src, 0o600) } // goType returns the Go type expression for a TypeRef. // Optional fields use pointer types for primitives and named types, // or rely on omitempty for slices and maps. parameter `optional` controls // whether to wrap pointer-style. func goType(tr spec.TypeRef, optional bool) string { switch tr.Kind { case spec.KindPrimitive: if optional && (tr.Name == "bool" || tr.Name == "int64" || tr.Name == "float64") { return "*" + tr.Name } return tr.Name case spec.KindNamed: // Named types are always pointer-optional when optional, except: // 1. Union (interface) types — they are naturally nil-able; pointer-to-interface is invalid. // 2. InputFile is always pointer-typed even when required: the // multipart helpers (fileCheck, multipartFileEntry) call // f.IsLocalUpload() and dereference Reader, both of which // expect a pointer receiver. if knownInterfaceTypes[tr.Name] { // Interface type — never add *. return tr.Name } if optional || tr.Name == "InputFile" { return "*" + tr.Name } return tr.Name case spec.KindArray: if tr.ElemType == nil { return "[]any" } // Inside slices, the element shape is its own thing — never wrap // the element in a pointer just because the field is optional. return "[]" + goType(*tr.ElemType, false) case spec.KindOneOf: // Integer-or-String: typed ChatID wrapper. if matchesVariants(tr.Variants, "int64", "string") { if optional { return "*ChatID" } return "ChatID" } // InputFile-or-String: *InputFile runtime helper handles both. if matchesVariants(tr.Variants, "InputFile", "string") { return "*InputFile" } // All-named variants sealed interface: fall back to interface. return "any" } return "any" } // unionField pairs a struct field with the name of its union type. type unionField struct { Field spec.Field UnionName string // e.g. "ChatMember" } // unionFieldsOf returns the subset of t.Fields whose type is a known // discriminated union (directly or as array element), per discs — the // merged union-level discriminator table (see mergeDiscriminators). func unionFieldsOf(t spec.TypeDecl, discs map[string]discriminatorSpec) []unionField { var out []unionField for _, f := range t.Fields { if u, ok := unionTypeFor(f.Type, discs); ok { out = append(out, unionField{Field: f, UnionName: u}) } } return out } // unionTypeFor inspects a TypeRef and reports whether it (or its array // element) is a known discriminated union in discs. Returns the union // name and true. func unionTypeFor(tr spec.TypeRef, discs map[string]discriminatorSpec) (string, bool) { switch tr.Kind { case spec.KindNamed: if _, ok := discs[tr.Name]; ok { return tr.Name, true } case spec.KindArray: if tr.ElemType != nil { return unionTypeFor(*tr.ElemType, discs) } case spec.KindOneOf: if u := unionNameByVariants(tr.Variants, discs); u != "" { return u, true } } return "", false } // unionNameByVariants finds the parent union whose variant type names exactly // match the given variant set (order-insensitive). func unionNameByVariants(variants []string, discs map[string]discriminatorSpec) string { for parentName, ds := range discs { wanted := make([]string, 0, len(ds.Variants)) for _, vt := range ds.Variants { wanted = append(wanted, vt) } if matchesVariants(variants, wanted...) { return parentName } } return "" } // hasUnionElem reports whether tr is an array whose element type is a known union. func hasUnionElem(tr spec.TypeRef, discs map[string]discriminatorSpec) bool { if tr.Kind != spec.KindArray || tr.ElemType == nil { return false } _, ok := unionTypeFor(*tr.ElemType, discs) return ok } // alternateVariant describes one synthetic sealed-interface variant the // generator emits for a union's non-object wire shape (see // spec.Alternate): a bare-JSON-string variant, or a variant holding a // JSON array of the union itself. Exactly one of IsString / IsArray is // set. types.tmpl emits the struct, marker method, and MarshalJSON for // each, and Unmarshal dispatches to it by inspecting the first // non-whitespace byte of the wire value. type alternateVariant struct { Name string // synthetic Go type name, e.g. "RichTextPlain" IsString bool IsArray bool } // unionAlternates maps a union TypeDecl's spec.Alternate entries (see // cmd/scrape's extractAlternates) to the synthetic Go variant // descriptors types.tmpl emits alongside the regular OneOf variants: // "Plain" for the bare-string shape, "Sequence" for the // array-of- shape. Returns nil for a union with no alternates, // which is every union except RichText as of Bot API v10 — so this is a // no-op for the rest of the type list. func unionAlternates(t spec.TypeDecl) []alternateVariant { if len(t.Alternates) == 0 { return nil } out := make([]alternateVariant, 0, len(t.Alternates)) for _, a := range t.Alternates { switch a.Shape { case spec.AlternateString: out = append(out, alternateVariant{Name: t.Name + "Plain", IsString: true}) case spec.AlternateArray: out = append(out, alternateVariant{Name: t.Name + "Sequence", IsArray: true}) } } return out } // matchesVariants reports whether got equals want as a set (order-insensitive). func matchesVariants(got []string, want ...string) bool { if len(got) != len(want) { return false } seen := make(map[string]int, len(got)) for _, g := range got { seen[g]++ } for _, w := range want { seen[w]-- } for _, v := range seen { if v != 0 { return false } } return true } // goField returns the Go struct-field declaration for a Field. // When the field carries scraper-detected enum values and the emitter // has a planned enum name for (parent, field), the field's Go type is // the enum identifier. Typed-string enums use the zero string "" // behaviour for omitempty, so we do not pointer-wrap optional enum // fields. Parent is "" for method parameters; pass the params type // name (e.g. "SendDiceParams") via overrideParent when calling from // the methods template so fieldTypeOverrides can resolve. func goField(plan *enumPlan, parent string, f spec.Field) string { return goFieldX(plan, parent, parent, f) } // goFieldX is the underlying field-emitter. enumParent is used for the // enum-plan lookup (which keys method params under ""); overrideParent // is used for fieldTypeOverrides (which keys method params under the // params type name). For struct types both are the same; for method // params they differ. func goFieldX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string { tag := fmt.Sprintf("`json:%q`", f.JSONName+omitempty(f)) if name := plan.FieldEnum(enumParent, f.Name); name != "" { return fmt.Sprintf("%s %s %s", f.Name, name, tag) } if name := fieldTypeOverride(overrideParent, f.Name); name != "" { return fmt.Sprintf("%s %s %s", f.Name, name, tag) } // Pinned companion-enum retype: allowed_updates is an Array of String // in the upstream spec, but the Go API exposes a hand-curated // UpdateType (api/enums.go) since the values are not enumerated // inline by Telegram. Retype []string → []UpdateType wherever the // wire field is allowed_updates so callers can pass typed constants // (api.UpdateMessage, ...) without string casts. Wire format is // unchanged: UpdateType is a typed string, marshals identically. if f.JSONName == "allowed_updates" && f.Type.Kind == spec.KindArray && f.Type.ElemType != nil && f.Type.ElemType.Kind == spec.KindPrimitive && f.Type.ElemType.Name == "string" { return fmt.Sprintf("%s []UpdateType %s", f.Name, tag) } return fmt.Sprintf("%s %s %s", f.Name, goType(f.Type, !f.Required), tag) } func omitempty(f spec.Field) string { if f.Required { return "" } return ",omitempty" } // docComment converts a doc string into a Go-style block comment with // a leading "// " on each line. func docComment(s string) string { if s == "" { return "" } var buf bytes.Buffer for _, line := range splitLines(s) { buf.WriteString("// ") buf.WriteString(line) buf.WriteByte('\n') } return buf.String() } func splitLines(s string) []string { var out []string start := 0 for i := 0; i < len(s); i++ { if s[i] == '\n' { out = append(out, s[start:i]) start = i + 1 } } if start < len(s) { out = append(out, s[start:]) } return out } // hasVariants reports whether the variant list contains all of the named strings (order-insensitive). func hasVariants(variants []string, names ...string) bool { return matchesVariants(variants, names...) } // buildUnionTypeSet returns the set of all type names that generate interface types // (i.e., types with one_of). This includes knownDiscriminators and marker-interface // unions not covered by the discriminator map. func buildUnionTypeSet(api *spec.API) map[string]bool { s := make(map[string]bool, len(knownDiscriminators)+16) for name := range knownDiscriminators { s[name] = true } for _, t := range api.Types { if len(t.OneOf) > 0 { s[t.Name] = true } } return s } // makeSentinelValue returns a sentinelValue func that uses the given union type set. // It returns a minimal valid Go expression for a spec.Field's type, // used in generated test param literals. plan supplies typed-enum names // so a method-param sentinel for a ParseMode field becomes a typed // constant rather than a magic string. func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(string, spec.Field) string { return func(methodName string, f spec.Field) string { return sentinelForField(methodName, f, unionTypes, plan) } } func sentinelForField(methodName string, f spec.Field, unionTypes map[string]bool, plan *enumPlan) string { if name := plan.FieldEnum(methodEnumParent(methodName), f.Name); name != "" && len(f.EnumValues) > 0 { return plan.ConstFor(name, f.EnumValues[0]) } tr := f.Type switch tr.Kind { case spec.KindPrimitive: switch tr.Name { case "int64": return "42" case "string": return `"test_value"` case "bool": return "true" case "float64": return "1.0" } case spec.KindNamed: switch tr.Name { case "ChatID": return "ChatIDFromInt(123)" case "InputFile": return `&InputFile{PathOrID: "file_id_test"}` } // Interface (union) types are nil-able. if unionTypes[tr.Name] { return "nil" } // Required named struct types are value types in the generated struct. if f.Required { return tr.Name + "{}" } return "&" + tr.Name + "{}" case spec.KindArray: return "nil" case spec.KindOneOf: if hasVariants(tr.Variants, "int64", "string") { return "ChatIDFromInt(123)" } if hasVariants(tr.Variants, "InputFile", "string") { return `&InputFile{PathOrID: "file_id_test"}` } // Sealed named-union interface: use nil (any). return "nil" } return "nil" } // successResp returns a backtick Go string literal containing a minimal // {"ok":true,"result":...} JSON body for the method's return type. func successResp(m spec.MethodDecl) string { body := successBody(m.Returns) return "`{\"ok\":true,\"result\":" + body + "}`" } func successBody(tr spec.TypeRef) string { switch tr.Kind { case spec.KindPrimitive: switch tr.Name { case "bool": return "true" case "int64", "float64": return "0" case "string": return `""` } case spec.KindNamed: if tr.Name == "MessageOrBool" { return "true" } // Sealed-interface unions need a discriminator field so UnmarshalXxx can dispatch. // Pick the lexicographically first variant value for determinism (map // iteration order in Go is randomized — using `range` directly produces // non-deterministic regen output). if disc, ok := knownDiscriminators[tr.Name]; ok && disc.Field != "" { values := make([]string, 0, len(disc.Variants)) for v := range disc.Variants { values = append(values, v) } sort.Strings(values) if len(values) > 0 { return fmt.Sprintf(`{"%s":"%s"}`, disc.Field, values[0]) } } // MaybeInaccessibleMessage uses date==0 → InaccessibleMessage variant. if tr.Name == "MaybeInaccessibleMessage" { return `{"date":0,"chat":{"id":1,"type":"private"},"message_id":1}` } return "{}" case spec.KindArray: return "[]" case spec.KindOneOf: return "null" } return "null" } // emitTests renders methods_gen_test.go. func (e *emitter) emitTests() error { unionTypes := buildUnionTypeSet(e.api) // Add test-specific helpers to the shared func map. fm := funcs(e.enums, e.discs) fm["sentinelValue"] = makeSentinelValue(unionTypes, e.enums) fm["successResp"] = successResp t, err := template.New("tests").Funcs(fm).Parse(testsTmpl) if err != nil { return fmt.Errorf("parse tests.tmpl: %w", err) } var buf bytes.Buffer if execErr := t.Execute(&buf, e.api); execErr != nil { return fmt.Errorf("execute tests.tmpl: %w", execErr) } src, err := format.Source(buf.Bytes()) if err != nil { return fmt.Errorf("gofmt methods_gen_test.go: %w\n--- unformatted ---\n%s", err, buf.String()) } return os.WriteFile(filepath.Join(e.outDir, "methods_gen_test.go"), src, 0o600) }