Files
miti99bot/internal/modules/monkeyd/monkeyd.go
T
tiennm99 85e07c7113 fix(monkeyd): make /monkeyd_crawl public and fix PDF font resolution
Advance the crawler submodule to the fix for the production failure "stat
usr/share/fonts/...: no such file or directory": the PDF writer was handing
fpdf a font path, and fpdf rewrote the absolute path into a
working-directory-relative one. Font data is now passed as bytes, with a
fallback font compiled into the binary when the host has none.

The runtime image therefore no longer installs DejaVuSans, which also removes
the font layer from the builder stage.

/monkeyd_crawl becomes public. The host allowlist and the single in-flight
export were already the controls that bound its cost; they now carry that job
alone, so both are load-bearing.
2026-07-29 23:46:33 +07:00

154 lines
4.7 KiB
Go

// Package monkeyd exports a monkeydd.com novel as a PDF and sends it back as a
// Telegram document. The crawling and rendering live in the monkeyd-crawler
// submodule (third_party/monkeyd-crawler); this module is the Telegram surface
// around it: argument validation, one-at-a-time scheduling, and delivery.
package monkeyd
import (
"context"
"fmt"
"strings"
"sync"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/monkeyd-crawler/export"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
// 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>"
// New is the module Factory. The module keeps no persistent state — an export
// is a one-shot job — so deps.Store is unused.
func New(_ modules.Deps) modules.Module {
return newModule(newRunner())
}
// newModule builds the module around a given runner, which is how tests supply
// one with a stubbed exporter and a synchronous launch.
func newModule(r *runner) modules.Module {
return modules.Module{
Commands: []modules.Command{
{
Name: commandName,
Visibility: modules.VisibilityPublic,
// Public despite being expensive: one invocation makes
// hundreds of outbound requests over several minutes. The
// 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>",
Handler: r.handle,
},
},
}
}
// runner serialises exports. The crawler spaces its own requests out per run,
// so two concurrent crawls would double the request rate against the site —
// and a novel is minutes of work, which makes queueing pointless. One at a
// time, globally, with a clear reply to anyone who asks meanwhile.
type runner struct {
mu sync.Mutex
running bool
current string // novel URL of the in-flight export, for the busy reply
// exporter is the crawl-and-render step. It is a field so tests can
// exercise scheduling and delivery without network access.
exporter func(context.Context, export.Request) (*export.Result, error)
// launch runs an export job. Production detaches it onto its own
// goroutine; tests substitute a synchronous run for determinism.
launch func(job func())
}
func newRunner() *runner {
return &runner{
exporter: export.Export,
launch: func(job func()) {
// Detached from the handler context on purpose: handlers run one
// at a time (the bot is built WithNotAsyncHandlers), so crawling
// inline would block every other command for minutes. The job
// owns its own timeout and recovers its own panics.
go job() //nolint:gosec // G118: intentional; see runner.export
},
}
}
// begin claims the single export slot, reporting the in-flight URL when it is
// already taken.
func (r *runner) begin(novelURL string) (ok bool, inFlight string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.running {
return false, r.current
}
r.running = true
r.current = novelURL
return true, ""
}
func (r *runner) end() {
r.mu.Lock()
defer r.mu.Unlock()
r.running = false
r.current = ""
}
func (r *runner) handle(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
arg := chathelper.ArgAfterCommand(msg.Text)
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)
if err != nil {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s.\n%s", capitalize(err.Error()), usage))
}
ok, inFlight := r.begin(novelURL)
if !ok {
return chathelper.Reply(ctx, b, msg,
"Already exporting "+inFlight+". Try again once it finishes.")
}
// Reply before starting so the user knows the wait is expected. If the
// reply cannot be delivered, drop the slot rather than crawling for a chat
// that will never hear the result.
if err := chathelper.Reply(ctx, b, msg,
"Exporting "+novelURL+" — this takes a few minutes. I will send the PDF here when it is ready."); err != nil {
r.end()
return err
}
r.launch(func() { r.export(b, msg, novelURL) })
return nil
}
// capitalize upper-cases the first letter so a lower-case error string reads as
// a sentence in a Telegram reply.
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}