mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-31 12:21:04 +00:00
Replies with a hashtag line led by #MonkeyD, then a blank line and the novel URL, sent as a code block so it can be copied in one tap. Each genre label becomes one hashtag with spaces and punctuation stripped and every word capitalised, since Telegram ends a hashtag at the first character that is not a letter, digit, or underscore. Diacritics survive; a label with no letters is dropped rather than emitted as a bare hash. The command costs one request and runs inline under a short timeout rather than in the background: handlers are dispatched one at a time, so a stalled fetch would block every other command. It shares the export page cache. Advance the submodule to the tag parser, which matches itemprop="genre" microdata so the site-wide genre navigation stays out of the result.
159 lines
5.0 KiB
Go
159 lines
5.0 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"
|
|
|
|
// 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.
|
|
func New(_ modules.Deps) modules.Module {
|
|
return newModule(newRunner(), fetchTags)
|
|
}
|
|
|
|
// newModule builds the module around a given runner and tag fetcher, which is
|
|
// how tests supply a stubbed exporter, a synchronous launch, and tags without
|
|
// network access.
|
|
func newModule(r *runner, fetch tagsFetcher) 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: parameters,
|
|
Handler: r.handle,
|
|
},
|
|
tagsCommand(fetch),
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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 {
|
|
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, args) })
|
|
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:]
|
|
}
|