feat(monkeyd): accept an optional font size argument

/monkeyd_crawl <url> [font_size] sets the body text size in points, half points
included, bounded to 6-24. Omitting it sends no size at all so the crawler's
default applies, rather than defining a second default here that could drift.
The document caption reports the size used.

Also advance the submodule to the lower 10pt default: on the 90x160mm phone
page that fits about 43 characters per line instead of 36.
This commit is contained in:
2026-07-30 00:16:57 +07:00
parent 85e07c7113
commit 345044f7de
8 changed files with 330 additions and 26 deletions
+12 -4
View File
@@ -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 <username>`, `/stats cmd <command_name>` |
| `monkeyd` | `/monkeyd_crawl <url>` — export a monkeydd.com novel as a PDF |
| `monkeyd` | `/monkeyd_crawl <url> [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 <url>` 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 <url> [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
+1 -1
View File
@@ -67,7 +67,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"gold_sell": "<luong>",
"lol": "[date]",
"loldle": "[champion]",
"monkeyd_crawl": "<url>",
"monkeyd_crawl": "<url> [font_size]",
"random": "<option,...>",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
+70
View File
@@ -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
}
+144
View File
@@ -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)
}
}
}
+20 -7
View File
@@ -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])
}
+68 -2
View File
@@ -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 != "<url>" {
t.Errorf("Parameters = %q, want %q", cmd.Parameters, "<url>")
if cmd.Parameters != "<url> [font_size]" {
t.Errorf("Parameters = %q, want %q", cmd.Parameters, "<url> [font_size]")
}
if cmd.Description == "" {
t.Error("Description is empty; command discovery requires one")
+14 -11
View File
@@ -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 + " <url>"
// parameters is the display syntax shared by the command menu, /help, and the
// usage text below, so the three cannot drift apart.
const parameters = "<url> [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: "<url>",
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
}