Skip to content

Commit 4a39e36

Browse files
ernadoclaude
andcommitted
feat(cmd/botdoc): add the API doc fetch/inspect CLI
Implement the cmd/botdoc tool referenced by architecture.md: fetch the published Bot API page (or read a local -file), extract it via internal/botdoc, and inspect it — summary (version + counts), -types, -methods, -name <def> details, or -json dump for cross-version diffing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 88fcf2a commit 4a39e36

2 files changed

Lines changed: 224 additions & 0 deletions

File tree

cmd/botdoc/main.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Command botdoc fetches the published Telegram Bot API documentation and
2+
// inspects the structured types and methods extracted from it. It is a
3+
// developer tool: a reference oracle while hand-writing the library and a way
4+
// to spot API drift.
5+
//
6+
// Examples:
7+
//
8+
// # Summary (version, counts) from the live docs:
9+
// go run ./cmd/botdoc
10+
//
11+
// # List every method name:
12+
// go run ./cmd/botdoc -methods
13+
//
14+
// # Show the fields and return type of one definition:
15+
// go run ./cmd/botdoc -name SendMessage
16+
// go run ./cmd/botdoc -name Message
17+
//
18+
// # Dump the whole extracted API as JSON (e.g. to diff across versions):
19+
// go run ./cmd/botdoc -json > api.json
20+
//
21+
// # Work offline from a saved page:
22+
// go run ./cmd/botdoc -file api.html -types
23+
package main
24+
25+
import (
26+
"context"
27+
"encoding/json"
28+
"flag"
29+
"fmt"
30+
"net/http"
31+
"os"
32+
"sort"
33+
"strings"
34+
"time"
35+
36+
"github.com/PuerkitoBio/goquery"
37+
38+
"github.com/gotd/botapi/internal/botdoc"
39+
)
40+
41+
const defaultURL = "https://core.telegram.org/bots/api"
42+
43+
func main() {
44+
if err := run(); err != nil {
45+
fmt.Fprintln(os.Stderr, "botdoc:", err)
46+
os.Exit(1)
47+
}
48+
}
49+
50+
func run() error {
51+
var (
52+
url = flag.String("url", defaultURL, "Bot API documentation URL to fetch")
53+
file = flag.String("file", "", "read the documentation HTML from a local file instead of fetching")
54+
asJSON = flag.Bool("json", false, "print the full extracted API as JSON")
55+
types = flag.Bool("types", false, "list all type names")
56+
methods = flag.Bool("methods", false, "list all method names")
57+
name = flag.String("name", "", "show details for a specific type or method")
58+
)
59+
flag.Parse()
60+
61+
doc, err := loadDocument(*url, *file)
62+
if err != nil {
63+
return err
64+
}
65+
api := botdoc.Extract(doc)
66+
67+
switch {
68+
case *asJSON:
69+
enc := json.NewEncoder(os.Stdout)
70+
enc.SetIndent("", " ")
71+
return enc.Encode(api)
72+
case *name != "":
73+
return printDefinition(api, *name)
74+
case *types:
75+
printNames(api.Types)
76+
return nil
77+
case *methods:
78+
printNames(api.Methods)
79+
return nil
80+
default:
81+
printSummary(api)
82+
return nil
83+
}
84+
}
85+
86+
// loadDocument reads the documentation HTML from a file or fetches it.
87+
func loadDocument(url, file string) (*goquery.Document, error) {
88+
if file != "" {
89+
f, err := os.Open(file) //nolint:gosec // a CLI reading a user-specified path is the intended behavior
90+
if err != nil {
91+
return nil, err
92+
}
93+
defer func() { _ = f.Close() }()
94+
return goquery.NewDocumentFromReader(f)
95+
}
96+
97+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
98+
defer cancel()
99+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
100+
if err != nil {
101+
return nil, err
102+
}
103+
resp, err := http.DefaultClient.Do(req)
104+
if err != nil {
105+
return nil, err
106+
}
107+
defer func() { _ = resp.Body.Close() }()
108+
if resp.StatusCode != http.StatusOK {
109+
return nil, fmt.Errorf("fetch %s: unexpected status %s", url, resp.Status)
110+
}
111+
return goquery.NewDocumentFromReader(resp.Body)
112+
}
113+
114+
func printSummary(api botdoc.API) {
115+
fmt.Printf("Bot API version: %s\n", api.Version)
116+
fmt.Printf("Types: %d\n", len(api.Types))
117+
fmt.Printf("Methods: %d\n", len(api.Methods))
118+
}
119+
120+
func printNames(defs []botdoc.Definition) {
121+
names := make([]string, len(defs))
122+
for i, d := range defs {
123+
names[i] = d.Name
124+
}
125+
sort.Strings(names)
126+
for _, n := range names {
127+
fmt.Println(n)
128+
}
129+
}
130+
131+
func printDefinition(api botdoc.API, name string) error {
132+
if d, ok := findDefinition(api.Types, name); ok {
133+
printDefinitionDetail("type", d)
134+
return nil
135+
}
136+
if d, ok := findDefinition(api.Methods, name); ok {
137+
printDefinitionDetail("method", d)
138+
return nil
139+
}
140+
return fmt.Errorf("no type or method named %q", name)
141+
}
142+
143+
// findDefinition matches a definition by name, case-insensitively.
144+
func findDefinition(defs []botdoc.Definition, name string) (botdoc.Definition, bool) {
145+
for _, d := range defs {
146+
if strings.EqualFold(d.Name, name) {
147+
return d, true
148+
}
149+
}
150+
return botdoc.Definition{}, false
151+
}
152+
153+
func printDefinitionDetail(kind string, d botdoc.Definition) {
154+
fmt.Printf("%s %s\n", kind, d.Name)
155+
if d.PrettyDescription != "" {
156+
fmt.Printf("\n%s\n", d.PrettyDescription)
157+
}
158+
if d.Ret != nil {
159+
fmt.Printf("\nreturns: %s\n", d.Ret)
160+
}
161+
if len(d.Fields) == 0 {
162+
return
163+
}
164+
165+
label := "Fields"
166+
if kind == "method" {
167+
label = "Parameters"
168+
}
169+
fmt.Printf("\n%s:\n", label)
170+
for _, f := range d.Fields {
171+
opt := ""
172+
if f.Optional {
173+
opt = " (optional)"
174+
}
175+
fmt.Printf(" %-28s %s%s\n", f.Name, f.Type.String(), opt)
176+
if len(f.Enum) > 0 {
177+
fmt.Printf(" %-28s one of: %s\n", "", strings.Join(f.Enum, ", "))
178+
}
179+
}
180+
}

cmd/botdoc/main_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package main
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/gotd/botapi/internal/botdoc"
8+
)
9+
10+
const testdata = "../../internal/botdoc/_testdata/api.html"
11+
12+
func TestLoadAndExtract(t *testing.T) {
13+
doc, err := loadDocument("", filepath.FromSlash(testdata))
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
api := botdoc.Extract(doc)
18+
if api.Version == "" {
19+
t.Fatal("expected a version")
20+
}
21+
if len(api.Types) == 0 || len(api.Methods) == 0 {
22+
t.Fatalf("expected types and methods, got %d/%d", len(api.Types), len(api.Methods))
23+
}
24+
}
25+
26+
func TestFindDefinition(t *testing.T) {
27+
doc, err := loadDocument("", filepath.FromSlash(testdata))
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
api := botdoc.Extract(doc)
32+
33+
// Case-insensitive match against a method.
34+
if _, ok := findDefinition(api.Methods, "sendmessage"); !ok {
35+
t.Fatal("sendMessage method should be found case-insensitively")
36+
}
37+
// And a type.
38+
if _, ok := findDefinition(api.Types, "Message"); !ok {
39+
t.Fatal("Message type should be found")
40+
}
41+
if _, ok := findDefinition(api.Types, "NoSuchThing"); ok {
42+
t.Fatal("unknown name should not be found")
43+
}
44+
}

0 commit comments

Comments
 (0)