mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add !adv alias, hospital insurance billing, wordle/UNO earnings tracking, streak death protection
- Add !adv shorthand for !adventure commands and DM replies - Hospital bill now shows insurance deduction and amount due - Refactor hospital nudge from per-goroutine sleep to shared ticker - Dead players' streaks are frozen; grace period on revival day - Track wordle earnings in wordle_stats, show in !stats and superstats - Show UNO net earnings and trivia accuracy % in superstats - Disable sentiment/profanity emoji reactions (classification still logs) - Add URL_PREVIEW_IGNORE_USERS env var to skip bot users - Add DISABLE_WOTD_POST env var, remove unused wotd.Prefetch() - README updates for all of the above Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ type AdventurePlugin struct {
|
||||
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
|
||||
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
|
||||
shopSessions sync.Map // userID string -> *advShopSession
|
||||
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
|
||||
morningHour int
|
||||
summaryHour int
|
||||
}
|
||||
@@ -120,6 +121,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.arenaAutoCashoutTicker()
|
||||
go p.rivalChallengeTicker()
|
||||
go p.robbieTicker()
|
||||
go p.hospitalNudgeTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -171,7 +173,7 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// 3. Command dispatch
|
||||
if !p.IsCommand(ctx.Body, "adventure") {
|
||||
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -180,6 +182,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
|
||||
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
|
||||
if args == "" && p.IsCommand(ctx.Body, "adv") {
|
||||
args = strings.TrimSpace(p.GetArgs(ctx.Body, "adv"))
|
||||
}
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
switch {
|
||||
@@ -491,12 +496,13 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
|
||||
body := strings.TrimSpace(ctx.Body)
|
||||
|
||||
// Skip if it looks like a command for another plugin
|
||||
if strings.HasPrefix(body, "!") && !strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||
lower := strings.ToLower(body)
|
||||
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip !adventure prefix if present — dispatch directly to avoid recursion
|
||||
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
|
||||
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
|
||||
return p.dispatchCommand(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ var hospitalFrequentCustomer = hospitalBillItem{
|
||||
}
|
||||
|
||||
// generateItemizedBill builds a procedural hospital bill that sums to the given total.
|
||||
func generateItemizedBill(total int64, hospitalVisits int, isHoliday bool) string {
|
||||
func generateItemizedBill(total int64, afterInsurance int64, hospitalVisits int, isHoliday bool) string {
|
||||
// Shuffle and pick 8-12 items from the pool
|
||||
pool := make([]hospitalBillItem, len(hospitalBillPool))
|
||||
copy(pool, hospitalBillPool)
|
||||
@@ -218,6 +218,27 @@ func generateItemizedBill(total int64, hospitalVisits int, isHoliday bool) strin
|
||||
sb.WriteString(strings.Repeat(" ", padding))
|
||||
sb.WriteString(formatBillAmount(total))
|
||||
sb.WriteByte('\n')
|
||||
|
||||
insLabel := "Guild Adventurer's Insurance"
|
||||
insPadding := maxName - len(insLabel) + 2
|
||||
if insPadding < 2 {
|
||||
insPadding = 2
|
||||
}
|
||||
sb.WriteString(insLabel)
|
||||
sb.WriteString(strings.Repeat(" ", insPadding))
|
||||
sb.WriteString("-" + formatBillAmount(total-afterInsurance))
|
||||
sb.WriteByte('\n')
|
||||
|
||||
sb.WriteString("─────────────────────────────────\n")
|
||||
oweLabel := "AMOUNT DUE"
|
||||
owePadding := maxName - len(oweLabel) + 2
|
||||
if owePadding < 2 {
|
||||
owePadding = 2
|
||||
}
|
||||
sb.WriteString(oweLabel)
|
||||
sb.WriteString(strings.Repeat(" ", owePadding))
|
||||
sb.WriteString(formatBillAmount(afterInsurance))
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString("```")
|
||||
|
||||
return sb.String()
|
||||
|
||||
@@ -75,8 +75,8 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
||||
sb.WriteString(admission)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Act 2 — The Bill (before insurance)
|
||||
sb.WriteString(generateItemizedBill(beforeInsurance, char.HospitalVisits, isHol))
|
||||
// Act 2 — The Bill
|
||||
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
|
||||
@@ -210,7 +210,8 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
||||
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
|
||||
cost := int64(char.CombatLevel) * 25_000
|
||||
beforeInsurance := int64(char.CombatLevel) * 125_000
|
||||
afterInsurance := int64(char.CombatLevel) * 25_000
|
||||
respawnTime := "unknown"
|
||||
if char.DeadUntil != nil {
|
||||
respawnTime = char.DeadUntil.Format("15:04")
|
||||
@@ -220,40 +221,57 @@ func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharac
|
||||
"🏥 **St. Guildmore's Memorial Hospital**\n\n"+
|
||||
"Nurse Joy has been notified. A bed is being prepared.\n\n"+
|
||||
"Type `!hospital` to check in for same-day revival.\n"+
|
||||
"Cost estimate: €%d (after Guild insurance)\n\n"+
|
||||
"Estimated bill: €%d (Guild insurance covers €%d → you pay €%d)\n\n"+
|
||||
"Or rest up — natural respawn at %s UTC.",
|
||||
cost, respawnTime)
|
||||
beforeInsurance, beforeInsurance-afterInsurance, afterInsurance, respawnTime)
|
||||
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
time.AfterFunc(10*time.Second, func() {
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send hospital ad", "user", userID, "err", err)
|
||||
return
|
||||
}
|
||||
p.scheduleHospitalNudge(userID)
|
||||
}()
|
||||
// Schedule nudge for 2 hours from now — checked by hospitalNudgeTicker
|
||||
p.hospitalNudges.Store(string(userID), time.Now().UTC().Add(2*time.Hour))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hospital Nudge (2-hour follow-up) ──────────────────────────────────────
|
||||
// ── Hospital Nudge Ticker ──────────────────────────────────────────────────
|
||||
// Runs every minute, checks for nudges that are due. No long-lived goroutines.
|
||||
|
||||
func (p *AdventurePlugin) scheduleHospitalNudge(userID id.UserID) {
|
||||
go func() {
|
||||
time.Sleep(2 * time.Hour)
|
||||
func (p *AdventurePlugin) hospitalNudgeTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char.Alive {
|
||||
return // already revived or error
|
||||
}
|
||||
|
||||
// Don't nudge if already in hospital flow
|
||||
if val, ok := p.pending.Load(string(userID)); ok {
|
||||
if pi, ok := val.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
|
||||
return
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
p.hospitalNudges.Range(func(key, val any) bool {
|
||||
uid := key.(string)
|
||||
nudgeAt := val.(time.Time)
|
||||
if now.Before(nudgeAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
|
||||
}
|
||||
}()
|
||||
// Due — remove regardless of outcome
|
||||
p.hospitalNudges.Delete(uid)
|
||||
|
||||
userID := id.UserID(uid)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char.Alive {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't nudge if already in hospital flow
|
||||
if v, ok := p.pending.Load(uid); ok {
|
||||
if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,11 +308,23 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
// Dead players freeze their streak — death is involuntary, don't punish it.
|
||||
if !char.Alive {
|
||||
continue
|
||||
}
|
||||
|
||||
if !char.ActionTakenToday {
|
||||
// If the player was recently dead (revived today but couldn't act
|
||||
// in time), preserve their streak instead of resetting it.
|
||||
// We detect this by checking if LastActionDate was yesterday —
|
||||
// they were active, then died, and couldn't act on revival day.
|
||||
recentlyDead := char.LastActionDate == today ||
|
||||
char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02")
|
||||
if recentlyDead {
|
||||
// Grace period — don't shame, don't reset
|
||||
continue
|
||||
}
|
||||
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
|
||||
@@ -444,40 +444,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
)
|
||||
}
|
||||
|
||||
// React with emojis based on sentiment
|
||||
sentimentEmojis := map[string]string{
|
||||
"positive": "\U0001f44d", // 👍
|
||||
"negative": "\U0001f44e", // 👎
|
||||
"excited": "\U0001f525", // 🔥
|
||||
"sarcastic": "\U0001f928", // 🤨
|
||||
"frustrated": "\U0001f62e\u200d\U0001f4a8", // 😮💨
|
||||
"curious": "\U0001f9d0", // 🧐
|
||||
"grateful": "\U0001f49c", // 💜
|
||||
"humorous": "\U0001f602", // 😂
|
||||
"supportive": "\U0001f917", // 🤗
|
||||
}
|
||||
if emoji, ok := sentimentEmojis[result.Sentiment]; ok && result.Sentiment != "neutral" {
|
||||
// Only react to strong sentiments (|score| > 0.5)
|
||||
score := result.SentimentScore
|
||||
if score > 0.5 || score < -0.5 {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, emoji)
|
||||
}
|
||||
}
|
||||
if result.Profanity {
|
||||
switch result.ProfanitySeverity {
|
||||
case 3:
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f92c") // 🤬
|
||||
case 2:
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f632") // 😲
|
||||
default:
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001fae3") // 🫣
|
||||
}
|
||||
}
|
||||
// Sentiment/profanity emoji reactions disabled — logging continues above.
|
||||
// Insult-response reactions (bot reacts when insulted) remain active above.
|
||||
if result.WOTDUsed {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book
|
||||
}
|
||||
if result.GratitudeTarget != "" {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // 💜 purple heart
|
||||
}
|
||||
|
||||
// Grant XP for gratitude
|
||||
|
||||
@@ -452,15 +452,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// Wordle
|
||||
var wPlayed, wSolved, wGuesses int
|
||||
var wPlayed, wSolved, wGuesses, wEarned int
|
||||
err = d.QueryRow(
|
||||
`SELECT puzzles_played, puzzles_solved, total_guesses
|
||||
`SELECT puzzles_played, puzzles_solved, total_guesses, total_earned
|
||||
FROM wordle_stats WHERE user_id = ?`, uid,
|
||||
).Scan(&wPlayed, &wSolved, &wGuesses)
|
||||
).Scan(&wPlayed, &wSolved, &wGuesses, &wEarned)
|
||||
if err == nil && wPlayed > 0 {
|
||||
avg := float64(wGuesses) / float64(wPlayed)
|
||||
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses\n",
|
||||
wSolved, wPlayed, avg))
|
||||
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses · €%d earned\n",
|
||||
wSolved, wPlayed, avg, wEarned))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
@@ -476,8 +476,9 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
|
||||
if tFastest > 0 {
|
||||
fastStr = fmt.Sprintf(" · fastest: %.1fs", float64(tFastest)/1000)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct%s\n",
|
||||
tCorrect, tCorrect+tWrong, fastStr))
|
||||
pct := float64(tCorrect) / float64(tCorrect+tWrong) * 100
|
||||
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct (%.0f%%)%s\n",
|
||||
tCorrect, tCorrect+tWrong, pct, fastStr))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
@@ -487,16 +488,33 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END), 0)
|
||||
FROM uno_games WHERE player_id = ?`, uid,
|
||||
).Scan(&unoSingleTotal, &unoSingleWins)
|
||||
var unoSingleEarned float64
|
||||
_ = d.QueryRow(
|
||||
`SELECT COALESCE(SUM(CASE
|
||||
WHEN result IN ('player_win','sudden_death_player') THEN pot_before - pot_after
|
||||
ELSE -wager END), 0)
|
||||
FROM uno_games WHERE player_id = ?`, uid,
|
||||
).Scan(&unoSingleEarned)
|
||||
var unoMultiWins, unoMultiTotal int
|
||||
_ = d.QueryRow(
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN winner_id = ? THEN 1 ELSE 0 END), 0)
|
||||
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
|
||||
).Scan(&unoMultiTotal, &unoMultiWins)
|
||||
var unoMultiEarned float64
|
||||
_ = d.QueryRow(
|
||||
`SELECT COALESCE(SUM(CASE WHEN winner_id = ? THEN pot_total - ante ELSE -ante END), 0)
|
||||
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
|
||||
).Scan(&unoMultiEarned)
|
||||
unoTotal := unoSingleTotal + unoMultiTotal
|
||||
unoWins := unoSingleWins + unoMultiWins
|
||||
unoEarned := unoSingleEarned + unoMultiEarned
|
||||
if unoTotal > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L\n",
|
||||
unoWins, unoTotal-unoWins))
|
||||
sign := "+"
|
||||
if unoEarned < 0 {
|
||||
sign = ""
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L · %s€%.0f net\n",
|
||||
unoWins, unoTotal-unoWins, sign, unoEarned))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
|
||||
@@ -20,16 +20,26 @@ var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
|
||||
// URLsPlugin detects URLs in messages and previews og:title/og:description.
|
||||
type URLsPlugin struct {
|
||||
Base
|
||||
enabled bool
|
||||
httpClient *http.Client
|
||||
enabled bool
|
||||
ignoreUsers map[string]struct{}
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewURLsPlugin creates a new URL preview plugin.
|
||||
func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
|
||||
enabled := os.Getenv("FEATURE_URL_PREVIEW") != ""
|
||||
ignore := make(map[string]struct{})
|
||||
if raw := os.Getenv("URL_PREVIEW_IGNORE_USERS"); raw != "" {
|
||||
for _, u := range strings.Split(raw, ",") {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
ignore[u] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &URLsPlugin{
|
||||
Base: NewBase(client),
|
||||
enabled: enabled,
|
||||
Base: NewBase(client),
|
||||
enabled: enabled,
|
||||
ignoreUsers: ignore,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
},
|
||||
@@ -56,6 +66,11 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip ignored users (e.g. news bots)
|
||||
if _, ok := p.ignoreUsers[string(ctx.Sender)]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
allURLs := urlRe.FindAllString(ctx.Body, -1)
|
||||
// Filter out Matrix internal links (user mentions, room links, etc.)
|
||||
var urls []string
|
||||
|
||||
@@ -685,6 +685,11 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
|
||||
}
|
||||
p.euro.Credit(uid, float64(amount), "wordle_win")
|
||||
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver})
|
||||
|
||||
// Track earnings in wordle_stats
|
||||
d := db.Get()
|
||||
d.Exec(`UPDATE wordle_stats SET total_earned = total_earned + ? WHERE user_id = ?`,
|
||||
amount, string(uid))
|
||||
}
|
||||
return payouts
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user