diff --git a/internal/db/db.go b/internal/db/db.go index 751ad45..e6901e1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -137,6 +137,7 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE adventure_characters ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE coop_dungeon_runs ADD COLUMN last_resolved_day INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE coop_dungeon_members ADD COLUMN member_payout INTEGER`, + `ALTER TABLE coop_dungeon_runs ADD COLUMN invite_post_id TEXT NOT NULL DEFAULT ''`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1387,6 +1388,7 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_runs ( gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion) last_resolved_day INTEGER NOT NULL DEFAULT 0, -- crash-resume guard: highest day whose floor outcome is final + invite_post_id TEXT NOT NULL DEFAULT '', -- Matrix event ID of the games-room invite (for pin/unpin) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, locked_at DATETIME, completed_at DATETIME diff --git a/internal/plugin/coop_dungeon.go b/internal/plugin/coop_dungeon.go index 1e3f92e..cfcbb4a 100644 --- a/internal/plugin/coop_dungeon.go +++ b/internal/plugin/coop_dungeon.go @@ -174,7 +174,11 @@ func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) er gr := gamesRoom() if gr != "" { - _ = p.SendMessage(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName)) + postID, err := p.SendMessageID(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName)) + if err == nil { + _ = setCoopRunInvitePostID(run.ID, postID) + _ = p.PinEvent(gr, postID) + } } return p.SendDM(ctx.Sender, fmt.Sprintf("⚔️ Tier %d co-op invite opened (#%d). Locks in 24h. Recruit your party.", tier, run.ID)) } @@ -353,6 +357,9 @@ func (p *AdventurePlugin) handleCoopCancel(ctx MessageContext) error { } gr := gamesRoom() if gr != "" { + if run.InvitePostID != "" { + _ = p.UnpinEvent(gr, run.InvitePostID) + } _ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d cancelled by the leader.", run.Tier, run.ID)) } return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID)) diff --git a/internal/plugin/coop_dungeon_db.go b/internal/plugin/coop_dungeon_db.go index 4a4e435..38a60a5 100644 --- a/internal/plugin/coop_dungeon_db.go +++ b/internal/plugin/coop_dungeon_db.go @@ -24,6 +24,7 @@ type CoopRun struct { GoldPool int RewardTotal int LastResolvedDay int + InvitePostID id.EventID CreatedAt time.Time LockedAt *time.Time CompletedAt *time.Time @@ -55,10 +56,11 @@ func loadCoopRun(runID int) (*CoopRun, error) { var leader string var lockedAt, completedAt sql.NullTime err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days, - base_difficulty, gold_pool, reward_total, last_resolved_day, created_at, locked_at, completed_at + base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at FROM coop_dungeon_runs WHERE id = ?`, runID).Scan( &r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay, + (*string)(&r.InvitePostID), &r.CreatedAt, &lockedAt, &completedAt) if err != nil { return nil, err @@ -94,7 +96,7 @@ func loadMostRecentOpenCoopRun() (*CoopRun, error) { func queryCoopRuns(whereClause string) ([]*CoopRun, error) { d := db.Get() rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days, - base_difficulty, gold_pool, reward_total, last_resolved_day, created_at, locked_at, completed_at + base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at FROM coop_dungeon_runs WHERE ` + whereClause) if err != nil { return nil, err @@ -108,6 +110,7 @@ func queryCoopRuns(whereClause string) ([]*CoopRun, error) { var lockedAt, completedAt sql.NullTime if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays, &r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay, + (*string)(&r.InvitePostID), &r.CreatedAt, &lockedAt, &completedAt); err != nil { return nil, err } @@ -169,6 +172,12 @@ func completeCoopRun(runID int, status string, reward int) error { return err } +func setCoopRunInvitePostID(runID int, postID id.EventID) error { + d := db.Get() + _, err := d.Exec(`UPDATE coop_dungeon_runs SET invite_post_id = ? WHERE id = ?`, string(postID), runID) + return err +} + // setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency. // Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op. func setCoopRunLastResolvedDay(runID, day int) error { diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go index 23fee7d..e9bfdc3 100644 --- a/internal/plugin/coop_dungeon_scheduler.go +++ b/internal/plugin/coop_dungeon_scheduler.go @@ -68,6 +68,9 @@ func (p *AdventurePlugin) coopProcessLocks() { _ = setCoopRunStatus(run.ID, "cancelled") gr := gamesRoom() if gr != "" { + if run.InvitePostID != "" { + _ = p.UnpinEvent(gr, run.InvitePostID) + } _ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d expired with no party. Cancelled.", run.Tier, run.ID)) } _ = p.SendDM(run.LeaderID, fmt.Sprintf("Co-op #%d expired before anyone joined. No party, no run.", run.ID)) @@ -94,6 +97,9 @@ func (p *AdventurePlugin) coopProcessLocks() { fresh, _ := loadCoopRun(run.ID) gr := gamesRoom() if gr != "" { + if run.InvitePostID != "" { + _ = p.UnpinEvent(gr, run.InvitePostID) + } _ = p.SendMessage(gr, renderCoopLock(fresh, members, p)) } // Per-player DM with funding instructions. diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index eb097ce..1638101 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -37,6 +37,10 @@ type MessageContext struct { Body string IsCommand bool // true if the message starts with the command prefix Event *event.Event + // OriginRoomID is set by plugins that rewrite RoomID for dispatch (e.g. holdem + // routes DM commands to the player's game room). When set, sender-private + // replies should go here instead of the rewritten RoomID. + OriginRoomID id.RoomID } // ReactionContext holds the context for a reaction event. @@ -703,6 +707,61 @@ func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) { return b.SendMessageID(roomID, text) } +// PinEvent appends the given event ID to the room's m.room.pinned_events +// state. No-op if already pinned. Requires the bot to have power level for +// state events; failures are logged and returned without retry. +func (b *Base) PinEvent(roomID id.RoomID, eventID id.EventID) error { + var content event.PinnedEventsEventContent + err := b.Client.StateEvent(context.Background(), roomID, event.StatePinnedEvents, "", &content) + if err != nil && !strings.Contains(err.Error(), "M_NOT_FOUND") { + slog.Error("pin: read state", "room", roomID, "err", err) + return err + } + for _, id := range content.Pinned { + if id == eventID { + return nil // already pinned + } + } + content.Pinned = append(content.Pinned, eventID) + _, err = b.Client.SendStateEvent(context.Background(), roomID, event.StatePinnedEvents, "", content) + if err != nil { + slog.Error("pin: write state", "room", roomID, "event", eventID, "err", err) + } + return err +} + +// UnpinEvent removes the given event ID from the room's pinned events. +// No-op if not currently pinned. +func (b *Base) UnpinEvent(roomID id.RoomID, eventID id.EventID) error { + var content event.PinnedEventsEventContent + err := b.Client.StateEvent(context.Background(), roomID, event.StatePinnedEvents, "", &content) + if err != nil { + if strings.Contains(err.Error(), "M_NOT_FOUND") { + return nil + } + slog.Error("unpin: read state", "room", roomID, "err", err) + return err + } + out := content.Pinned[:0] + found := false + for _, id := range content.Pinned { + if id == eventID { + found = true + continue + } + out = append(out, id) + } + if !found { + return nil + } + content.Pinned = out + _, err = b.Client.SendStateEvent(context.Background(), roomID, event.StatePinnedEvents, "", content) + if err != nil { + slog.Error("unpin: write state", "room", roomID, "event", eventID, "err", err) + } + return err +} + // EditMessage edits an existing message using Matrix m.replace relation. func (b *Base) EditMessage(roomID id.RoomID, eventID id.EventID, newText string) error { newContent := textContent(newText)