mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Pin co-op invite posts; unpin on lock/cancel
Adds Base.PinEvent / Base.UnpinEvent helpers (m.room.pinned_events state) that read-modify-write the existing pin list — idempotent on both sides. The bot needs power level for state events; failures are logged but not fatal. Schema: add coop_dungeon_runs.invite_post_id to remember which event to unpin. Migration entry included. Wired: - !coop start: pin the invite post in the games room - !coop cancel: unpin before posting cancellation - coopProcessLocks (auto-lock or auto-cancel): unpin before lock/expiry post Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 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_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_members ADD COLUMN member_payout INTEGER`,
|
||||||
|
`ALTER TABLE coop_dungeon_runs ADD COLUMN invite_post_id TEXT NOT NULL DEFAULT ''`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
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
|
gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding
|
||||||
reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion)
|
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
|
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,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
locked_at DATETIME,
|
locked_at DATETIME,
|
||||||
completed_at DATETIME
|
completed_at DATETIME
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) er
|
|||||||
|
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
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))
|
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()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
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))
|
_ = 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))
|
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type CoopRun struct {
|
|||||||
GoldPool int
|
GoldPool int
|
||||||
RewardTotal int
|
RewardTotal int
|
||||||
LastResolvedDay int
|
LastResolvedDay int
|
||||||
|
InvitePostID id.EventID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LockedAt *time.Time
|
LockedAt *time.Time
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
@@ -55,10 +56,11 @@ func loadCoopRun(runID int) (*CoopRun, error) {
|
|||||||
var leader string
|
var leader string
|
||||||
var lockedAt, completedAt sql.NullTime
|
var lockedAt, completedAt sql.NullTime
|
||||||
err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days,
|
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(
|
FROM coop_dungeon_runs WHERE id = ?`, runID).Scan(
|
||||||
&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
||||||
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
||||||
|
(*string)(&r.InvitePostID),
|
||||||
&r.CreatedAt, &lockedAt, &completedAt)
|
&r.CreatedAt, &lockedAt, &completedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -94,7 +96,7 @@ func loadMostRecentOpenCoopRun() (*CoopRun, error) {
|
|||||||
func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
|
func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days,
|
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)
|
FROM coop_dungeon_runs WHERE ` + whereClause)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -108,6 +110,7 @@ func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
|
|||||||
var lockedAt, completedAt sql.NullTime
|
var lockedAt, completedAt sql.NullTime
|
||||||
if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
||||||
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
||||||
|
(*string)(&r.InvitePostID),
|
||||||
&r.CreatedAt, &lockedAt, &completedAt); err != nil {
|
&r.CreatedAt, &lockedAt, &completedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -169,6 +172,12 @@ func completeCoopRun(runID int, status string, reward int) error {
|
|||||||
return err
|
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.
|
// setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency.
|
||||||
// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op.
|
// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op.
|
||||||
func setCoopRunLastResolvedDay(runID, day int) error {
|
func setCoopRunLastResolvedDay(runID, day int) error {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ func (p *AdventurePlugin) coopProcessLocks() {
|
|||||||
_ = setCoopRunStatus(run.ID, "cancelled")
|
_ = setCoopRunStatus(run.ID, "cancelled")
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
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.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))
|
_ = 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)
|
fresh, _ := loadCoopRun(run.ID)
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
if gr != "" {
|
if gr != "" {
|
||||||
|
if run.InvitePostID != "" {
|
||||||
|
_ = p.UnpinEvent(gr, run.InvitePostID)
|
||||||
|
}
|
||||||
_ = p.SendMessage(gr, renderCoopLock(fresh, members, p))
|
_ = p.SendMessage(gr, renderCoopLock(fresh, members, p))
|
||||||
}
|
}
|
||||||
// Per-player DM with funding instructions.
|
// Per-player DM with funding instructions.
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ type MessageContext struct {
|
|||||||
Body string
|
Body string
|
||||||
IsCommand bool // true if the message starts with the command prefix
|
IsCommand bool // true if the message starts with the command prefix
|
||||||
Event *event.Event
|
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.
|
// 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)
|
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.
|
// EditMessage edits an existing message using Matrix m.replace relation.
|
||||||
func (b *Base) EditMessage(roomID id.RoomID, eventID id.EventID, newText string) error {
|
func (b *Base) EditMessage(roomID id.RoomID, eventID id.EventID, newText string) error {
|
||||||
newContent := textContent(newText)
|
newContent := textContent(newText)
|
||||||
|
|||||||
Reference in New Issue
Block a user