mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +00:00
ac7cae8fa7
A fully-generated, strongly-typed Go client for the Telegram Bot API. * 176 methods + 301 types generated from Bot API v10.0 * 1408 auto-generated tests (8 scenarios per method) * Typed unions throughout — no 'any' in the public surface * Pluggable HTTP transport and JSON codec (default goccy/go-json) * Built-in retry middleware honouring Telegram's retry_after * Generic dispatcher with filters and conversation handlers * Self-verifying codegen pipeline (regen → audit → emit → run tests) * 14 example bots covering common patterns
292 lines
7.5 KiB
Go
292 lines
7.5 KiB
Go
// Command audit reports IR-level codegen fallbacks and signature drift.
|
|
//
|
|
// Usage:
|
|
//
|
|
// audit -ir <path> (default internal/spec/api.json)
|
|
// audit -overrides <path> (default internal/spec/overrides.json)
|
|
// audit -drift (compare against -against ref's IR; off by default)
|
|
// audit -against <ref> (git ref to diff drift against; default HEAD~1)
|
|
//
|
|
// Exit codes:
|
|
//
|
|
// 0 — clean
|
|
// 1 — unaccounted bool fallbacks or any-typed fields
|
|
// 2 — drift detected (signature changed)
|
|
// 3 — invalid IR or overrides
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"github.com/goccy/go-json"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
|
)
|
|
|
|
const (
|
|
exitClean = 0
|
|
exitFallback = 1
|
|
exitDrift = 2
|
|
exitInvalid = 3
|
|
)
|
|
|
|
func main() {
|
|
irPath := flag.String("ir", "internal/spec/api.json", "path to IR JSON")
|
|
ovPath := flag.String("overrides", "internal/spec/overrides.json", "path to overrides JSON")
|
|
checkDrift := flag.Bool("drift", false, "compare against -against ref's IR for signature changes")
|
|
againstRef := flag.String("against", "HEAD~1", "git ref to diff drift against (e.g. origin/main, HEAD~1)")
|
|
flag.Parse()
|
|
|
|
api, err := loadIR(*irPath)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "audit:", err)
|
|
os.Exit(exitInvalid)
|
|
}
|
|
overrides, err := spec.LoadOverrides(*ovPath)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "audit:", err)
|
|
os.Exit(exitInvalid)
|
|
}
|
|
|
|
var problems []string
|
|
|
|
problems = append(problems, auditBool(api, overrides)...)
|
|
problems = append(problems, auditAny(api)...)
|
|
|
|
driftFound := false
|
|
if *checkDrift {
|
|
if d, err := auditDrift(*irPath, *againstRef, api); err != nil {
|
|
fmt.Fprintln(os.Stderr, "audit: drift check skipped:", err)
|
|
} else if len(d) > 0 {
|
|
fmt.Println("Drift detected (signatures changed since HEAD):")
|
|
for _, p := range d {
|
|
fmt.Println(" ", p)
|
|
}
|
|
driftFound = true
|
|
}
|
|
}
|
|
|
|
if len(problems) == 0 && !driftFound {
|
|
fmt.Println("audit: clean")
|
|
os.Exit(exitClean)
|
|
}
|
|
|
|
if len(problems) > 0 {
|
|
fmt.Println("Codegen fallbacks requiring action:")
|
|
for _, p := range problems {
|
|
fmt.Println(" ", p)
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("To resolve: extend cmd/scrape/method.go regex patterns OR")
|
|
fmt.Println("add an entry to internal/spec/overrides.json.")
|
|
os.Exit(exitFallback)
|
|
}
|
|
|
|
// drift only, no fallbacks
|
|
os.Exit(exitDrift)
|
|
}
|
|
|
|
func loadIR(path string) (*spec.API, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open IR: %w", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
var api spec.API
|
|
if err := json.NewDecoder(f).Decode(&api); err != nil {
|
|
return nil, fmt.Errorf("decode IR: %w", err)
|
|
}
|
|
return &api, nil
|
|
}
|
|
|
|
// auditBool returns problems for methods returning bool whose docs don't
|
|
// actually say "Returns True" / etc. and which aren't in the approved list.
|
|
func auditBool(api *spec.API, ov *spec.Overrides) []string {
|
|
var out []string
|
|
for _, m := range api.Methods {
|
|
if m.Returns.Kind != spec.KindPrimitive || m.Returns.Name != "bool" {
|
|
continue
|
|
}
|
|
if ov.IsBoolApproved(m.Name) {
|
|
continue
|
|
}
|
|
if looksGenuinelyBool(m.Doc) {
|
|
continue
|
|
}
|
|
snippet := m.Doc
|
|
if len(snippet) > 120 {
|
|
snippet = snippet[:120] + "…"
|
|
}
|
|
out = append(out, fmt.Sprintf("bool fallback: %s — doc: %q", m.Name, snippet))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func looksGenuinelyBool(doc string) bool {
|
|
for _, p := range []string{
|
|
"Returns True", "Returns true",
|
|
"True is returned", "true is returned",
|
|
"Returns Boolean", "Returns Bool",
|
|
} {
|
|
if strings.Contains(doc, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// auditAny scans the IR for any KindOneOf TypeRef that would render as
|
|
// `any` in generated code (not matched by ChatID/InputFile-or-string/known
|
|
// union heuristics). Reports each occurrence with location.
|
|
func auditAny(api *spec.API) []string {
|
|
var out []string
|
|
isKnownUnion := func(variants []string) bool {
|
|
if hasVariants(variants, "int64", "string") {
|
|
return true // ChatID
|
|
}
|
|
if hasVariants(variants, "InputFile", "string") {
|
|
return true // *InputFile
|
|
}
|
|
// ReplyMarkup union: all four keyboard types — emitter renders as `any` intentionally
|
|
if hasVariants(variants, "InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply") {
|
|
return true
|
|
}
|
|
for _, t := range api.Types {
|
|
if len(t.OneOf) > 0 && sameSet(variants, t.OneOf) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
isAny := func(tr spec.TypeRef) bool {
|
|
return tr.Kind == spec.KindOneOf && !isKnownUnion(tr.Variants)
|
|
}
|
|
for _, t := range api.Types {
|
|
for _, f := range t.Fields {
|
|
if isAny(f.Type) {
|
|
out = append(out, fmt.Sprintf("any field: %s.%s (variants=%v)", t.Name, f.Name, f.Type.Variants))
|
|
}
|
|
}
|
|
}
|
|
for _, m := range api.Methods {
|
|
if isAny(m.Returns) {
|
|
out = append(out, fmt.Sprintf("any return: %s (variants=%v)", m.Name, m.Returns.Variants))
|
|
}
|
|
for _, p := range m.Params {
|
|
if isAny(p.Type) {
|
|
out = append(out, fmt.Sprintf("any param: %s.%s (variants=%v)", m.Name, p.Name, p.Type.Variants))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hasVariants(got []string, want ...string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
seen := map[string]int{}
|
|
for _, g := range got {
|
|
seen[g]++
|
|
}
|
|
for _, w := range want {
|
|
seen[w]--
|
|
}
|
|
for _, v := range seen {
|
|
if v != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sameSet(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
return hasVariants(a, b...)
|
|
}
|
|
|
|
// auditDrift compares method/return signatures between the given git ref's
|
|
// version of irPath and the in-memory current IR. Returns a list of
|
|
// human-readable change descriptions.
|
|
func auditDrift(irPath, againstRef string, current *spec.API) ([]string, error) {
|
|
cmd := exec.Command("git", "show", againstRef+":"+irPath) // #nosec G204 - operator tool, ref controlled by caller
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git show %s: %w", againstRef, err)
|
|
}
|
|
var prev spec.API
|
|
if err := json.Unmarshal(out, &prev); err != nil {
|
|
return nil, fmt.Errorf("decode %s IR: %w", againstRef, err)
|
|
}
|
|
return diffSignatures(&prev, current), nil
|
|
}
|
|
|
|
func diffSignatures(prev, cur *spec.API) []string {
|
|
var changes []string
|
|
|
|
pmeth := indexByName(prev.Methods, func(m spec.MethodDecl) string { return m.Name })
|
|
cmeth := indexByName(cur.Methods, func(m spec.MethodDecl) string { return m.Name })
|
|
|
|
for name, p := range pmeth {
|
|
c, ok := cmeth[name]
|
|
if !ok {
|
|
changes = append(changes, fmt.Sprintf("removed method: %s", name))
|
|
continue
|
|
}
|
|
if !typeRefEqual(p.Returns, c.Returns) {
|
|
changes = append(changes, fmt.Sprintf(
|
|
"method %s return changed: %s → %s",
|
|
name, formatTypeRef(p.Returns), formatTypeRef(c.Returns)))
|
|
}
|
|
}
|
|
for name := range cmeth {
|
|
if _, ok := pmeth[name]; !ok {
|
|
changes = append(changes, fmt.Sprintf("added method: %s", name))
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func indexByName[T any](xs []T, f func(T) string) map[string]T {
|
|
out := map[string]T{}
|
|
for _, x := range xs {
|
|
out[f(x)] = x
|
|
}
|
|
return out
|
|
}
|
|
|
|
func typeRefEqual(a, b spec.TypeRef) bool {
|
|
if a.Kind != b.Kind || a.Name != b.Name {
|
|
return false
|
|
}
|
|
if (a.ElemType == nil) != (b.ElemType == nil) {
|
|
return false
|
|
}
|
|
if a.ElemType != nil && !typeRefEqual(*a.ElemType, *b.ElemType) {
|
|
return false
|
|
}
|
|
return sameSet(a.Variants, b.Variants)
|
|
}
|
|
|
|
func formatTypeRef(t spec.TypeRef) string {
|
|
switch t.Kind {
|
|
case spec.KindPrimitive:
|
|
return t.Name
|
|
case spec.KindNamed:
|
|
return t.Name
|
|
case spec.KindArray:
|
|
if t.ElemType != nil {
|
|
return "[]" + formatTypeRef(*t.ElemType)
|
|
}
|
|
return "[]any"
|
|
case spec.KindOneOf:
|
|
return "(" + strings.Join(t.Variants, " | ") + ")"
|
|
}
|
|
return "?"
|
|
}
|