mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-03 12:22:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/monkeyd-crawler updated: fb903e3262...f400a83678
Reference in New Issue
Block a user