Files
miti99bot/internal/modules/monkeyd/monkeyd.go
T
tiennm99 d62e8a72b3 feat(monkeyd): export monkeydd.com novels as PDF via /monkeyd_crawl
Add the monkeyd module, which crawls a novel and sends the rendered PDF back
as a Telegram document. Crawling and rendering come from the monkeyd-crawler
submodule, resolved through a go.mod replace directive.

The command is admin-only and restricted to monkeydd.com: one run makes
hundreds of outbound requests over minutes, and the extractor only understands
that site. Exports run one at a time and on a detached goroutine, because
handlers are dispatched synchronously and an inline crawl would block every
other command.

The runtime image gains DejaVuSans; font discovery probes system paths and the
distroless base ships none, so PDF rendering would otherwise fail in
production. CI checks out submodules and the builder copies the submodule
go.mod before go mod download, which needs it to resolve the build list.
2026-07-29 23:07:23 +07:00

152 lines
4.6 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.VisibilityProtected,
// One invocation makes hundreds of outbound requests over
// several minutes, so it stays off the public surface.
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:]
}