diff --git a/README.md b/README.md index f08a4ba..5864d1e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Atlas via long polling and an in-process cron scheduler. | `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) | | `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) | | `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | -| `monkeyd` | `/monkeyd_crawl ` — export a monkeydd.com novel as a PDF | +| `monkeyd` | `/monkeyd_crawl [font_size]` — export a monkeydd.com novel as a PDF | Disable modules with the `MODULES` environment variable. @@ -132,12 +132,20 @@ prevents a position opened after Record date from applying an older event. ### Novel PDF export -`/monkeyd_crawl ` downloads every chapter of a monkeydd.com novel and -sends it back as a single PDF document, sized for reading on a phone. The -crawling and rendering come from the +`/monkeyd_crawl [font_size]` downloads every chapter of a monkeydd.com +novel and sends it back as a single PDF document, sized for reading on a phone. +The crawling and rendering come from the [monkeyd-crawler](https://github.com/tiennm99/monkeyd-crawler) submodule; the module is the Telegram surface around it. +`font_size` is the body text size in points and accepts half points. It ranges +from 6 to 24 and defaults to the crawler's own default of 10, which fits roughly +43 characters per line across 26 lines on the 90×160 mm page. Larger values +trade characters per line for legibility: 12 gives about 36. Headings and the +title page scale with it. Omitting the argument passes no size at all, so the +crawler's default applies rather than a second one defined here. The document +caption reports the size that was used. + The command is public, so any member of a chat can run it. One invocation makes hundreds of outbound requests spread over several minutes, so two things bound the cost and are deliberate: only `monkeydd.com` URLs are accepted, and exactly diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 5c25fea..0d9f5fd 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -67,7 +67,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "gold_sell": "", "lol": "[date]", "loldle": "[champion]", - "monkeyd_crawl": "", + "monkeyd_crawl": " [font_size]", "random": "", "stats": "[users | user | cmd ]", "stock_events": " [days]", diff --git a/internal/modules/monkeyd/crawl_args.go b/internal/modules/monkeyd/crawl_args.go new file mode 100644 index 0000000..44775dc --- /dev/null +++ b/internal/modules/monkeyd/crawl_args.go @@ -0,0 +1,70 @@ +package monkeyd + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +// Font size bounds for the optional argument. The body font drives the heading +// and title sizes too, and the default page is only 78mm wide between margins, +// so the useful range is narrow: at 6pt a line holds about 72 characters, at +// 24pt about 18. +const ( + minFontSize = 6.0 + maxFontSize = 24.0 +) + +var errTooManyArgs = errors.New("too many arguments") + +// crawlArgs is a validated /monkeyd_crawl invocation. +type crawlArgs struct { + // NovelURL is normalised and known to be on an allowed host. + NovelURL string + + // FontSize is the requested body font size in points, or 0 to leave the + // crawler's default in place. + FontSize float64 +} + +// parseCrawlArgs validates the text after the command: a novel URL, optionally +// followed by a body font size. +func parseCrawlArgs(arg string) (crawlArgs, error) { + fields := strings.Fields(arg) + if len(fields) == 0 { + return crawlArgs{}, errNotAURL + } + if len(fields) > 2 { + return crawlArgs{}, errTooManyArgs + } + + novelURL, err := normalizeNovelURL(fields[0]) + if err != nil { + return crawlArgs{}, err + } + parsed := crawlArgs{NovelURL: novelURL} + + if len(fields) == 2 { + if parsed.FontSize, err = parseFontSize(fields[1]); err != nil { + return crawlArgs{}, err + } + } + return parsed, nil +} + +// parseFontSize accepts a font size in points, including half points, and +// rejects anything outside the readable range. +func parseFontSize(raw string) (float64, error) { + size, err := strconv.ParseFloat(raw, 64) + // ParseFloat accepts "NaN" and "Inf", neither of which is a font size, and + // both would reach the renderer as a silently broken layout. + if err != nil || math.IsNaN(size) || math.IsInf(size, 0) { + return 0, fmt.Errorf("font size %q is not a number", raw) + } + if size < minFontSize || size > maxFontSize { + return 0, fmt.Errorf("font size must be between %g and %g points", minFontSize, maxFontSize) + } + return size, nil +} diff --git a/internal/modules/monkeyd/crawl_args_test.go b/internal/modules/monkeyd/crawl_args_test.go new file mode 100644 index 0000000..dffc9c7 --- /dev/null +++ b/internal/modules/monkeyd/crawl_args_test.go @@ -0,0 +1,144 @@ +package monkeyd + +import ( + "strings" + "testing" +) + +func TestParseCrawlArgs(t *testing.T) { + tests := []struct { + name string + arg string + wantURL string + wantFontSize float64 + wantErr bool + }{ + { + name: "url only leaves the font size unset", + arg: testNovelURL, + wantURL: testNovelURL, + wantFontSize: 0, + }, + { + name: "url with font size", + arg: testNovelURL + " 14", + wantURL: testNovelURL, + wantFontSize: 14, + }, + { + name: "half point font size", + arg: testNovelURL + " 10.5", + wantURL: testNovelURL, + wantFontSize: 10.5, + }, + { + name: "extra whitespace between arguments", + arg: " " + testNovelURL + " 12 ", + wantURL: testNovelURL, + wantFontSize: 12, + }, + { + name: "bare host gets a scheme", + arg: "monkeydd.com/novel.html 9", + wantURL: "https://monkeydd.com/novel.html", + wantFontSize: 9, + }, + { + name: "font size at the lower bound", + arg: testNovelURL + " 6", + wantURL: testNovelURL, + wantFontSize: minFontSize, + }, + { + name: "font size at the upper bound", + arg: testNovelURL + " 24", + wantURL: testNovelURL, + wantFontSize: maxFontSize, + }, + { + name: "empty argument", + arg: "", + wantErr: true, + }, + { + name: "font size below the lower bound", + arg: testNovelURL + " 5", + wantErr: true, + }, + { + name: "font size above the upper bound", + arg: testNovelURL + " 25", + wantErr: true, + }, + { + name: "zero font size", + arg: testNovelURL + " 0", + wantErr: true, + }, + { + name: "negative font size", + arg: testNovelURL + " -12", + wantErr: true, + }, + { + name: "non-numeric font size", + arg: testNovelURL + " big", + wantErr: true, + }, + { + name: "NaN font size", + arg: testNovelURL + " NaN", + wantErr: true, + }, + { + name: "infinite font size", + arg: testNovelURL + " Inf", + wantErr: true, + }, + { + name: "too many arguments", + arg: testNovelURL + " 12 a5", + wantErr: true, + }, + { + name: "disallowed host", + arg: "https://example.com/novel.html 12", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCrawlArgs(tt.arg) + if tt.wantErr { + if err == nil { + t.Fatalf("parseCrawlArgs(%q) = %+v, want error", tt.arg, got) + } + return + } + if err != nil { + t.Fatalf("parseCrawlArgs(%q) returned error: %v", tt.arg, err) + } + if got.NovelURL != tt.wantURL { + t.Errorf("NovelURL = %q, want %q", got.NovelURL, tt.wantURL) + } + if got.FontSize != tt.wantFontSize { + t.Errorf("FontSize = %v, want %v", got.FontSize, tt.wantFontSize) + } + }) + } +} + +// The out-of-range message must state the bounds; "invalid font size" alone +// leaves the user guessing what to type instead. +func TestParseFontSizeErrorNamesTheBounds(t *testing.T) { + _, err := parseFontSize("99") + if err == nil { + t.Fatal("expected an error for 99") + } + for _, want := range []string{"6", "24"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention the bound %q", err, want) + } + } +} diff --git a/internal/modules/monkeyd/export_job.go b/internal/modules/monkeyd/export_job.go index eeb6e8c..2091928 100644 --- a/internal/modules/monkeyd/export_job.go +++ b/internal/modules/monkeyd/export_job.go @@ -43,7 +43,8 @@ const cacheDirName = "miti99bot-monkeyd-cache" // export runs one crawl to completion and delivers the PDF. It is called on its // own goroutine, detached from the Telegram handler context. -func (r *runner) export(b *bot.Bot, msg *models.Message, novelURL string) { +func (r *runner) export(b *bot.Bot, msg *models.Message, args crawlArgs) { + novelURL := args.NovelURL defer r.end() // A panic here would reach no handler recover and would take the whole // process down with it, so contain it. @@ -74,6 +75,9 @@ func (r *runner) export(b *bot.Bot, msg *models.Message, novelURL string) { result, err := r.exporter(ctx, export.Request{ NovelURL: novelURL, OutDir: outDir, + // Zero means the caller gave no font size, which Export reads as + // "use the default" — exactly the intent. + FontSize: args.FontSize, CacheDir: filepath.Join(os.TempDir(), cacheDirName), // Per-chapter progress is one line per chapter — useful when // diagnosing a stuck export, too noisy for the default level. @@ -90,14 +94,23 @@ func (r *runner) export(b *bot.Bot, msg *models.Message, novelURL string) { log.Info("monkeyd export done", "command", commandName, "url", novelURL, "title", result.Title, "chapters", result.Chapters, "words", result.Words) - if err := sendPDF(b, msg, result); err != nil { + if err := sendPDF(b, msg, result, effectiveFontSize(args.FontSize)); err != nil { log.Error("monkeyd delivery failed", "command", commandName, "url", novelURL, "err", err) r.reportFailure(b, msg, "The novel was exported but the PDF could not be sent: "+err.Error()) } } +// effectiveFontSize resolves what the renderer actually used, so the caption +// can report a real number rather than "default". +func effectiveFontSize(requested float64) float64 { + if requested == 0 { + return export.DefaultFontSize + } + return requested +} + // sendPDF uploads the finished book as a Telegram document. -func sendPDF(b *bot.Bot, msg *models.Message, result *export.Result) error { +func sendPDF(b *bot.Bot, msg *models.Message, result *export.Result, fontSize float64) error { info, err := os.Stat(result.Path) if err != nil { return fmt.Errorf("stat pdf: %w", err) @@ -125,7 +138,7 @@ func sendPDF(b *bot.Bot, msg *models.Message, result *export.Result) error { Filename: filepath.Base(result.Path), Data: file, }, - Caption: caption(result), + Caption: caption(result, fontSize), }) return err } @@ -135,9 +148,9 @@ const captionLimit = 1024 // caption describes the book under the document. Plain text, so a title // containing markup characters needs no escaping. -func caption(result *export.Result) string { - text := fmt.Sprintf("%s\n%s page (%.0f x %.0f mm)\n%s", - result.Summary(), result.Page.Name, result.Page.W, result.Page.H, result.SourceURL) +func caption(result *export.Result, fontSize float64) string { + text := fmt.Sprintf("%s\n%s page (%.0f x %.0f mm), %gpt\n%s", + result.Summary(), result.Page.Name, result.Page.W, result.Page.H, fontSize, result.SourceURL) if runes := []rune(text); len(runes) > captionLimit { text = string(runes[:captionLimit]) } diff --git a/internal/modules/monkeyd/handlers_test.go b/internal/modules/monkeyd/handlers_test.go index b512a5e..06ca029 100644 --- a/internal/modules/monkeyd/handlers_test.go +++ b/internal/modules/monkeyd/handlers_test.go @@ -3,6 +3,7 @@ package monkeyd import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -134,6 +135,71 @@ func TestCrawl_SendsPDFOnSuccess(t *testing.T) { } } +func TestCrawl_PassesRequestedFontSizeToExporter(t *testing.T) { + rb, r := install(t, 999) + dir := t.TempDir() + + var gotRequest export.Request + r.exporter = func(_ context.Context, req export.Request) (*export.Result, error) { + gotRequest = req + return stubPDF(t, dir, "Example-Novel.pdf", 2048), nil + } + + rb.Bot.ProcessUpdate(context.Background(), + testutil.NewPrivateMessage(999, "/monkeyd_crawl "+testNovelURL+" 14")) + + if gotRequest.FontSize != 14 { + t.Errorf("exporter got FontSize %v, want 14", gotRequest.FontSize) + } + if caption := rb.LastSent().Form["caption"]; !strings.Contains(caption, "14pt") { + t.Errorf("caption = %q, want it to report 14pt", caption) + } +} + +// Omitting the argument must leave Export to apply its own default rather than +// the bot inventing one, so the two cannot disagree. +func TestCrawl_OmittedFontSizeLeavesRequestZero(t *testing.T) { + rb, r := install(t, 999) + dir := t.TempDir() + + var gotRequest export.Request + r.exporter = func(_ context.Context, req export.Request) (*export.Result, error) { + gotRequest = req + return stubPDF(t, dir, "Example-Novel.pdf", 2048), nil + } + + rb.Bot.ProcessUpdate(context.Background(), + testutil.NewPrivateMessage(999, "/monkeyd_crawl "+testNovelURL)) + + if gotRequest.FontSize != 0 { + t.Errorf("exporter got FontSize %v, want 0 (defer to Export)", gotRequest.FontSize) + } + // The caption still has to name a real size, not "0pt". + want := fmt.Sprintf("%gpt", export.DefaultFontSize) + if caption := rb.LastSent().Form["caption"]; !strings.Contains(caption, want) { + t.Errorf("caption = %q, want it to report %s", caption, want) + } +} + +func TestCrawl_RejectsBadFontSize(t *testing.T) { + rb, r := install(t, 999) + called := false + r.exporter = func(context.Context, export.Request) (*export.Result, error) { + called = true + return nil, nil + } + + rb.Bot.ProcessUpdate(context.Background(), + testutil.NewPrivateMessage(999, "/monkeyd_crawl "+testNovelURL+" 100")) + + if called { + t.Error("exporter ran despite an out-of-range font size") + } + if got := rb.LastSent().Text(); !strings.Contains(got, "between 6 and 24") { + t.Errorf("reply = %q, want it to state the font size bounds", got) + } +} + func TestCrawl_ReportsExportFailure(t *testing.T) { rb, r := install(t, 999) r.exporter = func(context.Context, export.Request) (*export.Result, error) { @@ -279,8 +345,8 @@ func TestRegistration(t *testing.T) { if cmd.Visibility != modules.VisibilityPublic { t.Errorf("Visibility = %v, want Public", cmd.Visibility) } - if cmd.Parameters != "" { - t.Errorf("Parameters = %q, want %q", cmd.Parameters, "") + if cmd.Parameters != " [font_size]" { + t.Errorf("Parameters = %q, want %q", cmd.Parameters, " [font_size]") } if cmd.Description == "" { t.Error("Description is empty; command discovery requires one") diff --git a/internal/modules/monkeyd/monkeyd.go b/internal/modules/monkeyd/monkeyd.go index 6f3735e..c2092a6 100644 --- a/internal/modules/monkeyd/monkeyd.go +++ b/internal/modules/monkeyd/monkeyd.go @@ -22,9 +22,16 @@ import ( // commandName is the single command this module exposes. const commandName = "monkeyd_crawl" -// usage is shown when the command arrives without a usable URL. It repeats the -// Parameters syntax so the error and the command menu agree. -const usage = "Usage: /" + commandName + " " +// parameters is the display syntax shared by the command menu, /help, and the +// usage text below, so the three cannot drift apart. +const parameters = " [font_size]" + +// usage is shown when the command arrives without usable arguments. The bounds +// and default are read from their definitions rather than restated, so the +// message stays true if either changes. +var usage = fmt.Sprintf( + "Usage: /%s %s\nfont_size is in points, %g to %g (default %g).", + commandName, parameters, minFontSize, maxFontSize, export.DefaultFontSize) // New is the module Factory. The module keeps no persistent state — an export // is a one-shot job — so deps.Store is unused. @@ -45,7 +52,7 @@ func newModule(r *runner) modules.Module { // host allowlist and the single in-flight export are what // bound the cost, so both are load-bearing here. Description: "Export a " + AllowedHostsHint + " novel as a PDF", - Parameters: "", + Parameters: parameters, Handler: r.handle, }, }, @@ -113,16 +120,12 @@ func (r *runner) handle(ctx context.Context, b *bot.Bot, update *models.Update) if arg == "" { return chathelper.Reply(ctx, b, msg, usage) } - // Telegram may hand the URL over with trailing punctuation or a stray - // second word; only the first token can be the URL. - if fields := strings.Fields(arg); len(fields) > 0 { - arg = fields[0] - } - novelURL, err := normalizeNovelURL(arg) + args, err := parseCrawlArgs(arg) if err != nil { return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s.\n%s", capitalize(err.Error()), usage)) } + novelURL := args.NovelURL ok, inFlight := r.begin(novelURL) if !ok { @@ -139,7 +142,7 @@ func (r *runner) handle(ctx context.Context, b *bot.Bot, update *models.Update) return err } - r.launch(func() { r.export(b, msg, novelURL) }) + r.launch(func() { r.export(b, msg, args) }) return nil } diff --git a/third_party/monkeyd-crawler b/third_party/monkeyd-crawler index fb903e3..f400a83 160000 --- a/third_party/monkeyd-crawler +++ b/third_party/monkeyd-crawler @@ -1 +1 @@ -Subproject commit fb903e3262acbea4c941d5778b245574aa313290 +Subproject commit f400a83678a513f0448e6881c61560fc9771aa22