mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Space inviter: DM admin to confirm invites for Space-less local users
New optional plugin (FEATURE_SPACE_INVITER) that detects local homeserver users who aren't in any Matrix Space and prompts the configured admin via DM with yes/no/ignore. Persistent prompt log in space_inviter_prompts so restarts and upgrades don't re-ping for users who already replied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
README.md
16
README.md
@@ -32,6 +32,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
|||||||
- **50 plugins** with dependency injection and ordered registration
|
- **50 plugins** with dependency injection and ordered registration
|
||||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure 2.0 (D&D-layered RPG: 7 races, 5 classes with 3 subclasses each through L15, real spell slots and forward-simulating combat engine, 10 hand-tuned dungeon zones across 5 tiers, multi-day expeditions with supply burn / threat clock / day cycle / multi-region travel / extract-and-resume / per-zone temporal events, harvest nodes per room, TwinBee as a mood-driven DM with ten zone-specific narration pools, all on top of the existing economy/housing/pets/Misty/Arina/blacksmith/hospital/rivals systems), Arena (5-tier combat gauntlet with 20 unique monsters, streak-based payouts, death lockout, leaderboard), Co-op Dungeons (2–7 day party runs with funding tiers, TwinBee-narrated floor events with voting, spectator parimutuel betting, basket/mimic gift system, weighted-roll item distribution including masterworks at T4–T5), all with channel restriction
|
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure 2.0 (D&D-layered RPG: 7 races, 5 classes with 3 subclasses each through L15, real spell slots and forward-simulating combat engine, 10 hand-tuned dungeon zones across 5 tiers, multi-day expeditions with supply burn / threat clock / day cycle / multi-region travel / extract-and-resume / per-zone temporal events, harvest nodes per room, TwinBee as a mood-driven DM with ten zone-specific narration pools, all on top of the existing economy/housing/pets/Misty/Arina/blacksmith/hospital/rivals systems), Arena (5-tier combat gauntlet with 20 unique monsters, streak-based payouts, death lockout, leaderboard), Co-op Dungeons (2–7 day party runs with funding tiers, TwinBee-narrated floor events with voting, spectator parimutuel betting, basket/mimic gift system, weighted-roll item distribution including masterworks at T4–T5), all with channel restriction
|
||||||
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
|
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
|
||||||
|
- **Space inviter** (optional) - detects local homeserver users who aren't in any Matrix Space and DMs the configured admin to ask whether to invite them to a target Space. Replies are `yes` / `no` / `ignore`. Persistent prompt log in SQLite means restarts/upgrades don't re-ping the admin about users they've already answered. Runs on startup sweep + reactive on join events.
|
||||||
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
|
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
|
||||||
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
|
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
|
||||||
- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries
|
- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries
|
||||||
@@ -150,6 +151,7 @@ Everything is configured through environment variables or a `.env` file.
|
|||||||
| `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts |
|
| `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts |
|
||||||
| `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) |
|
| `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) |
|
||||||
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
|
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
|
||||||
|
| `FEATURE_SPACE_INVITER` | Set to `true` to enable the space inviter (disabled by default) |
|
||||||
| `FEATURE_MARKET` | Set to `true` to enable the market overview plugin |
|
| `FEATURE_MARKET` | Set to `true` to enable the market overview plugin |
|
||||||
| `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms |
|
| `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms |
|
||||||
| `DISABLE_WOTD_POST` | Set to `true` to suppress daily WOTD auto-post (the `!wotd` command and passive WOTD usage tracking still work) |
|
| `DISABLE_WOTD_POST` | Set to `true` to suppress daily WOTD auto-post (the `!wotd` command and passive WOTD usage tracking still work) |
|
||||||
@@ -228,6 +230,20 @@ All moderation settings are optional. The system is disabled unless `FEATURE_MOD
|
|||||||
| `SPACE_GROUP_THRESHOLD` | `50` | Percentage of the smaller room's members that must overlap to group rooms together (1–100) |
|
| `SPACE_GROUP_THRESHOLD` | `50` | Percentage of the smaller room's members that must overlap to group rooms together (1–100) |
|
||||||
| `HOLIDAY_COUNTRIES` | `US` | Comma-separated ISO country codes for Calendarific holiday posts |
|
| `HOLIDAY_COUNTRIES` | `US` | Comma-separated ISO country codes for Calendarific holiday posts |
|
||||||
|
|
||||||
|
### Space Inviter
|
||||||
|
|
||||||
|
Disabled unless `FEATURE_SPACE_INVITER=true`. Requires the bot to be joined to **every** Space on the homeserver — if a Space exists that the bot can't see, users in only that Space will be misclassified as Space-less. Uses the Synapse Admin API to enumerate local users.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SPACE_INVITER_TARGET` | | Room ID of the Space to invite users into on `yes` reply |
|
||||||
|
| `SPACE_INVITER_HOME_DOMAIN` | | Your homeserver domain (e.g. `parodia.dev`) — only users with this suffix are considered |
|
||||||
|
| `SPACE_INVITER_SYNAPSE_URL` | | Base URL for Synapse admin API (e.g. `https://parodia.dev`) |
|
||||||
|
| `SPACE_INVITER_ADMIN_TOKEN` | | Synapse admin access token |
|
||||||
|
| `SPACE_INVITER_ADMIN_USER` | | User ID to DM with prompts (the operator) |
|
||||||
|
| `SPACE_INVITER_DELAY` | `2s` | Delay between prompts during the startup sweep |
|
||||||
|
| `SPACE_INVITER_COOLDOWN` | `720h` | Minimum gap between re-prompts for the same user (Go duration). Prevents restart/upgrade ping-spam — a `no` reply is honored for this long across restarts. `ignore` is permanent. |
|
||||||
|
|
||||||
### Missing Members
|
### Missing Members
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
@@ -1823,6 +1823,21 @@ CREATE TABLE IF NOT EXISTS player_meta (
|
|||||||
display_name TEXT NOT NULL DEFAULT '',
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
hospital_visits INTEGER NOT NULL DEFAULT 0
|
hospital_visits INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS space_inviter_prompts (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
prompt_sent_at INTEGER NOT NULL,
|
||||||
|
trigger_kind TEXT NOT NULL,
|
||||||
|
dm_room_id TEXT NOT NULL,
|
||||||
|
dm_event_id TEXT NOT NULL,
|
||||||
|
reply TEXT,
|
||||||
|
replied_at INTEGER,
|
||||||
|
invite_attempted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
invite_outcome TEXT,
|
||||||
|
invite_error TEXT,
|
||||||
|
PRIMARY KEY (user_id, prompt_sent_at)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_space_inviter_user ON space_inviter_prompts(user_id);
|
||||||
`
|
`
|
||||||
|
|
||||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
603
internal/plugin/space_inviter.go
Normal file
603
internal/plugin/space_inviter.go
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpaceInviterPlugin DMs the configured admin whenever a local homeserver
|
||||||
|
// user is detected as belonging to zero Matrix Spaces. The admin replies
|
||||||
|
// `yes` / `no` / `ignore` in the DM room; on `yes` the bot invites the user
|
||||||
|
// to the configured target Space.
|
||||||
|
type SpaceInviterPlugin struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
enabled bool
|
||||||
|
cfg spaceInviterConfig
|
||||||
|
|
||||||
|
// Resolved at Init: DM room with admin
|
||||||
|
adminDMRoom id.RoomID
|
||||||
|
|
||||||
|
// In-memory: user IDs we've sent a (still-pending) prompt for this session.
|
||||||
|
// DB is the source of truth across restarts; this just short-circuits before
|
||||||
|
// hitting SQLite on the hot path of the reactive handler.
|
||||||
|
pendingMu sync.Mutex
|
||||||
|
pending map[id.UserID]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type spaceInviterConfig struct {
|
||||||
|
TargetSpaceID id.RoomID
|
||||||
|
HomeDomain string
|
||||||
|
SynapseBaseURL string
|
||||||
|
AdminAccessToken string
|
||||||
|
AdminUserID id.UserID
|
||||||
|
InviteDelay time.Duration
|
||||||
|
RepromptCooldown time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSpaceInviterConfig() spaceInviterConfig {
|
||||||
|
return spaceInviterConfig{
|
||||||
|
TargetSpaceID: id.RoomID(os.Getenv("SPACE_INVITER_TARGET")),
|
||||||
|
HomeDomain: os.Getenv("SPACE_INVITER_HOME_DOMAIN"),
|
||||||
|
SynapseBaseURL: strings.TrimRight(os.Getenv("SPACE_INVITER_SYNAPSE_URL"), "/"),
|
||||||
|
AdminAccessToken: os.Getenv("SPACE_INVITER_ADMIN_TOKEN"),
|
||||||
|
AdminUserID: id.UserID(os.Getenv("SPACE_INVITER_ADMIN_USER")),
|
||||||
|
InviteDelay: envDur("SPACE_INVITER_DELAY", 2*time.Second),
|
||||||
|
RepromptCooldown: envDur("SPACE_INVITER_COOLDOWN", 30*24*time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDur(key string, fallback time.Duration) time.Duration {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpaceInviterPlugin(client *mautrix.Client) *SpaceInviterPlugin {
|
||||||
|
cfg := loadSpaceInviterConfig()
|
||||||
|
enabled := strings.EqualFold(os.Getenv("FEATURE_SPACE_INVITER"), "true")
|
||||||
|
if !enabled {
|
||||||
|
slog.Info("space_inviter: disabled (set FEATURE_SPACE_INVITER=true to enable)")
|
||||||
|
}
|
||||||
|
return &SpaceInviterPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
enabled: enabled,
|
||||||
|
cfg: cfg,
|
||||||
|
pending: make(map[id.UserID]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) Name() string { return "space_inviter" }
|
||||||
|
func (p *SpaceInviterPlugin) Commands() []CommandDef { return nil }
|
||||||
|
func (p *SpaceInviterPlugin) OnReaction(ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) Init() error {
|
||||||
|
if !p.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.validateConfig(); err != nil {
|
||||||
|
return fmt.Errorf("space_inviter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Permission check: bot must be in target space and able to invite.
|
||||||
|
if _, err := p.Client.JoinedMembers(ctx, p.cfg.TargetSpaceID); err != nil {
|
||||||
|
return fmt.Errorf("space_inviter: bot not in target space %s: %w", p.cfg.TargetSpaceID, err)
|
||||||
|
}
|
||||||
|
if err := p.checkInvitePower(ctx); err != nil {
|
||||||
|
return fmt.Errorf("space_inviter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve DM room with admin.
|
||||||
|
dm, err := p.GetDMRoom(p.cfg.AdminUserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("space_inviter: resolve admin DM: %w", err)
|
||||||
|
}
|
||||||
|
p.adminDMRoom = dm
|
||||||
|
|
||||||
|
// Sanity log: enumerate Spaces the bot is in.
|
||||||
|
spaces := p.discoverSpaces(ctx)
|
||||||
|
slog.Info("space_inviter: ready",
|
||||||
|
"target_space", p.cfg.TargetSpaceID,
|
||||||
|
"admin", p.cfg.AdminUserID,
|
||||||
|
"dm_room", p.adminDMRoom,
|
||||||
|
"known_spaces", len(spaces),
|
||||||
|
)
|
||||||
|
if len(spaces) <= 1 {
|
||||||
|
slog.Warn("space_inviter: bot is in only the target space — 'in any space' check degenerates to 'in target space'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate pending set from DB (rows with reply IS NULL).
|
||||||
|
p.hydratePending()
|
||||||
|
|
||||||
|
// Kick off startup sweep in the background; we don't want to block bot startup.
|
||||||
|
go p.runStartupSweep()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) validateConfig() error {
|
||||||
|
missing := []string{}
|
||||||
|
if p.cfg.TargetSpaceID == "" {
|
||||||
|
missing = append(missing, "SPACE_INVITER_TARGET")
|
||||||
|
}
|
||||||
|
if p.cfg.HomeDomain == "" {
|
||||||
|
missing = append(missing, "SPACE_INVITER_HOME_DOMAIN")
|
||||||
|
}
|
||||||
|
if p.cfg.SynapseBaseURL == "" {
|
||||||
|
missing = append(missing, "SPACE_INVITER_SYNAPSE_URL")
|
||||||
|
}
|
||||||
|
if p.cfg.AdminAccessToken == "" {
|
||||||
|
missing = append(missing, "SPACE_INVITER_ADMIN_TOKEN")
|
||||||
|
}
|
||||||
|
if p.cfg.AdminUserID == "" {
|
||||||
|
missing = append(missing, "SPACE_INVITER_ADMIN_USER")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("missing required config: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) checkInvitePower(ctx context.Context) error {
|
||||||
|
var pl event.PowerLevelsEventContent
|
||||||
|
if err := p.Client.StateEvent(ctx, p.cfg.TargetSpaceID, event.StatePowerLevels, "", &pl); err != nil {
|
||||||
|
return fmt.Errorf("read target space power levels: %w", err)
|
||||||
|
}
|
||||||
|
want := pl.Invite()
|
||||||
|
have := pl.GetUserLevel(p.Client.UserID)
|
||||||
|
if have < want {
|
||||||
|
return fmt.Errorf("bot power level %d in target space < required invite level %d", have, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Space membership
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// discoverSpaces returns the set of Space room IDs the bot is currently joined to.
|
||||||
|
func (p *SpaceInviterPlugin) discoverSpaces(ctx context.Context) []id.RoomID {
|
||||||
|
resp, err := p.Client.JoinedRooms(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("space_inviter: list joined rooms", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spaces := make([]id.RoomID, 0)
|
||||||
|
for _, room := range resp.JoinedRooms {
|
||||||
|
var ce event.CreateEventContent
|
||||||
|
if err := p.Client.StateEvent(ctx, room, event.StateCreate, "", &ce); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ce.Type == event.RoomTypeSpace {
|
||||||
|
spaces = append(spaces, room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spaces
|
||||||
|
}
|
||||||
|
|
||||||
|
// usersInAnySpace returns the union of joined+invited members across every
|
||||||
|
// Space the bot is in. Pending invites are read from the room state.
|
||||||
|
func (p *SpaceInviterPlugin) usersInAnySpace(ctx context.Context) map[id.UserID]struct{} {
|
||||||
|
out := make(map[id.UserID]struct{})
|
||||||
|
for _, space := range p.discoverSpaces(ctx) {
|
||||||
|
// Joined members
|
||||||
|
jm, err := p.Client.JoinedMembers(ctx, space)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("space_inviter: joined members", "space", space, "err", err)
|
||||||
|
} else {
|
||||||
|
for uid := range jm.Joined {
|
||||||
|
out[uid] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pending invites — pull all m.room.member state events
|
||||||
|
members, err := p.Client.Members(ctx, space)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("space_inviter: members state", "space", space, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, evt := range members.Chunk {
|
||||||
|
mem := evt.Content.AsMember()
|
||||||
|
if mem == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mem.Membership == event.MembershipInvite {
|
||||||
|
out[id.UserID(evt.GetStateKey())] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Synapse admin API: enumerate local users
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type synapseUser struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserType string `json:"user_type"`
|
||||||
|
Deactivated int `json:"deactivated"`
|
||||||
|
IsGuest int `json:"is_guest"`
|
||||||
|
Admin int `json:"admin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) listLocalUsers(ctx context.Context) ([]synapseUser, error) {
|
||||||
|
out := []synapseUser{}
|
||||||
|
from := 0
|
||||||
|
const limit = 100
|
||||||
|
for {
|
||||||
|
u := fmt.Sprintf("%s/_synapse/admin/v2/users?guests=false&deactivated=false&from=%d&limit=%d",
|
||||||
|
p.cfg.SynapseBaseURL, from, limit)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.cfg.AdminAccessToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("admin api: %w", err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("admin api status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var page struct {
|
||||||
|
Users []synapseUser `json:"users"`
|
||||||
|
NextToken string `json:"next_token"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &page); err != nil {
|
||||||
|
return nil, fmt.Errorf("admin api decode: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, page.Users...)
|
||||||
|
if page.NextToken == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(page.NextToken)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
from = n
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) eligibleUser(uid id.UserID, userType string, isGuest, deactivated int) bool {
|
||||||
|
if uid == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(string(uid), ":"+p.cfg.HomeDomain) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if uid == p.Client.UserID || uid == p.cfg.AdminUserID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if userType != "" {
|
||||||
|
// Synapse marks bots/appservice puppets with non-empty user_type
|
||||||
|
// (e.g. "bot", "support", or appservice-set values). Skip them.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isGuest != 0 || deactivated != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Eligibility (DB-backed)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// dbEligible returns true if a fresh prompt may be sent for user. Encodes the
|
||||||
|
// pending / ignore / cooldown rules from the spec.
|
||||||
|
func (p *SpaceInviterPlugin) dbEligible(uid id.UserID) bool {
|
||||||
|
cooldownCutoff := time.Now().Add(-p.cfg.RepromptCooldown).Unix()
|
||||||
|
row := db.Get().QueryRow(`
|
||||||
|
SELECT 1 FROM space_inviter_prompts
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (reply IS NULL OR reply = 'ignore' OR prompt_sent_at > ?)
|
||||||
|
LIMIT 1`,
|
||||||
|
string(uid), cooldownCutoff)
|
||||||
|
var n int
|
||||||
|
err := row.Scan(&n)
|
||||||
|
if err == nil {
|
||||||
|
return false // a blocking row exists
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydratePending preloads the in-memory pending set from DB rows with NULL reply.
|
||||||
|
func (p *SpaceInviterPlugin) hydratePending() {
|
||||||
|
rows, err := db.Get().Query(`SELECT user_id FROM space_inviter_prompts WHERE reply IS NULL`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("space_inviter: hydrate pending", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
p.pendingMu.Lock()
|
||||||
|
defer p.pendingMu.Unlock()
|
||||||
|
for rows.Next() {
|
||||||
|
var u string
|
||||||
|
if err := rows.Scan(&u); err == nil {
|
||||||
|
p.pending[id.UserID(u)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Startup sweep
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) runStartupSweep() {
|
||||||
|
ctx := context.Background()
|
||||||
|
users, err := p.listLocalUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("space_inviter: startup sweep aborted", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inSpace := p.usersInAnySpace(ctx)
|
||||||
|
|
||||||
|
totalEligible, prompts := 0, 0
|
||||||
|
for _, u := range users {
|
||||||
|
uid := id.UserID(u.Name)
|
||||||
|
if !p.eligibleUser(uid, u.UserType, u.IsGuest, u.Deactivated) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalEligible++
|
||||||
|
if _, ok := inSpace[uid]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !p.dbEligible(uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Pending invite to target?
|
||||||
|
if p.hasPendingInvite(ctx, uid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.sendPrompt(uid, "sweep"); err != nil {
|
||||||
|
slog.Warn("space_inviter: send prompt failed", "user", uid, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prompts++
|
||||||
|
time.Sleep(p.cfg.InviteDelay)
|
||||||
|
}
|
||||||
|
slog.Info("space_inviter: startup sweep complete",
|
||||||
|
"total_local_users", len(users),
|
||||||
|
"eligible_after_filter", totalEligible,
|
||||||
|
"prompts_sent", prompts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPendingInvite returns true if uid is currently invited to the target Space.
|
||||||
|
func (p *SpaceInviterPlugin) hasPendingInvite(ctx context.Context, uid id.UserID) bool {
|
||||||
|
var mem event.MemberEventContent
|
||||||
|
err := p.Client.StateEvent(ctx, p.cfg.TargetSpaceID, event.StateMember, string(uid), &mem)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mem.Membership == event.MembershipInvite
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reactive handler — called from main.go on m.room.member joins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// OnMemberJoin is invoked by the global member event handler in main.go for
|
||||||
|
// any join event. It only acts on local-domain users.
|
||||||
|
func (p *SpaceInviterPlugin) OnMemberJoin(uid id.UserID) {
|
||||||
|
if !p.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.eligibleUser(uid, "", 0, 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.pendingMu.Lock()
|
||||||
|
if _, already := p.pending[uid]; already {
|
||||||
|
p.pendingMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pendingMu.Unlock()
|
||||||
|
|
||||||
|
if !p.dbEligible(uid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
inSpace := p.usersInAnySpace(ctx)
|
||||||
|
if _, ok := inSpace[uid]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.hasPendingInvite(ctx, uid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.sendPrompt(uid, "reactive"); err != nil {
|
||||||
|
slog.Warn("space_inviter: reactive prompt failed", "user", uid, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt + reply
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) sendPrompt(uid id.UserID, trigger string) error {
|
||||||
|
text := fmt.Sprintf(
|
||||||
|
"🐝 `%s` isn't in any Space on the homeserver.\n"+
|
||||||
|
"Want me to invite them to the target Space?\n\n"+
|
||||||
|
"Reply with:\n"+
|
||||||
|
" `yes` — invite them\n"+
|
||||||
|
" `no` — skip for now (won't ask again for %s)\n"+
|
||||||
|
" `ignore` — never ask about this user again",
|
||||||
|
uid, formatCooldown(p.cfg.RepromptCooldown),
|
||||||
|
)
|
||||||
|
evtID, err := p.SendMessageID(p.adminDMRoom, text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err = db.Get().Exec(`
|
||||||
|
INSERT INTO space_inviter_prompts
|
||||||
|
(user_id, prompt_sent_at, trigger_kind, dm_room_id, dm_event_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
string(uid), now, trigger, string(p.adminDMRoom), string(evtID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record prompt: %w", err)
|
||||||
|
}
|
||||||
|
p.pendingMu.Lock()
|
||||||
|
p.pending[uid] = struct{}{}
|
||||||
|
p.pendingMu.Unlock()
|
||||||
|
slog.Info("space_inviter: prompt_sent", "user", uid, "trigger", trigger, "event", evtID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCooldown(d time.Duration) string {
|
||||||
|
if d >= 24*time.Hour {
|
||||||
|
return fmt.Sprintf("%d days", int(d.Hours())/24)
|
||||||
|
}
|
||||||
|
if d >= time.Hour {
|
||||||
|
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||||
|
}
|
||||||
|
return d.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles admin replies in the DM room. Other messages pass through.
|
||||||
|
func (p *SpaceInviterPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ctx.RoomID != p.adminDMRoom {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ctx.Sender != p.cfg.AdminUserID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||||
|
if reply != "yes" && reply != "no" && reply != "ignore" {
|
||||||
|
return nil // silently ignore unknown replies
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recent pending prompt in this room. If the admin's message
|
||||||
|
// is a Matrix reply, prefer the row matching that target event.
|
||||||
|
targetEvent := ""
|
||||||
|
if rel := ctx.Event.Content.AsMessage().RelatesTo; rel != nil && rel.InReplyTo != nil {
|
||||||
|
targetEvent = string(rel.InReplyTo.EventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, promptAt, err := p.findPendingPrompt(targetEvent)
|
||||||
|
if err != nil {
|
||||||
|
return nil // nothing pending
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, _ = db.Get().Exec(`
|
||||||
|
UPDATE space_inviter_prompts
|
||||||
|
SET reply = ?, replied_at = ?
|
||||||
|
WHERE user_id = ? AND prompt_sent_at = ?`,
|
||||||
|
reply, now, string(user), promptAt,
|
||||||
|
)
|
||||||
|
p.pendingMu.Lock()
|
||||||
|
delete(p.pending, user)
|
||||||
|
p.pendingMu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("space_inviter: prompt_reply", "user", user, "reply", reply)
|
||||||
|
|
||||||
|
if reply == "yes" {
|
||||||
|
p.attemptInvite(user, promptAt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) findPendingPrompt(targetEvent string) (id.UserID, int64, error) {
|
||||||
|
if targetEvent != "" {
|
||||||
|
row := db.Get().QueryRow(`
|
||||||
|
SELECT user_id, prompt_sent_at FROM space_inviter_prompts
|
||||||
|
WHERE dm_event_id = ? AND reply IS NULL
|
||||||
|
LIMIT 1`, targetEvent)
|
||||||
|
var u string
|
||||||
|
var t int64
|
||||||
|
if err := row.Scan(&u, &t); err == nil {
|
||||||
|
return id.UserID(u), t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: most recent pending prompt in the admin DM.
|
||||||
|
row := db.Get().QueryRow(`
|
||||||
|
SELECT user_id, prompt_sent_at FROM space_inviter_prompts
|
||||||
|
WHERE dm_room_id = ? AND reply IS NULL
|
||||||
|
ORDER BY prompt_sent_at DESC
|
||||||
|
LIMIT 1`, string(p.adminDMRoom))
|
||||||
|
var u string
|
||||||
|
var t int64
|
||||||
|
if err := row.Scan(&u, &t); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return id.UserID(u), t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SpaceInviterPlugin) attemptInvite(uid id.UserID, promptAt int64) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Re-validate at reply time: still Space-less, no pending invite.
|
||||||
|
if p.hasPendingInvite(ctx, uid) {
|
||||||
|
_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome='skipped_race' WHERE user_id=? AND prompt_sent_at=?`,
|
||||||
|
string(uid), promptAt)
|
||||||
|
_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("ℹ️ `%s` already has a pending invite — skipped.", uid))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := p.usersInAnySpace(ctx)[uid]; ok {
|
||||||
|
_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome='skipped_race' WHERE user_id=? AND prompt_sent_at=?`,
|
||||||
|
string(uid), promptAt)
|
||||||
|
_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("ℹ️ `%s` is already in a Space — skipped.", uid))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.Client.InviteUser(ctx, p.cfg.TargetSpaceID, &mautrix.ReqInviteUser{UserID: uid})
|
||||||
|
if err == nil {
|
||||||
|
_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome='ok' WHERE user_id=? AND prompt_sent_at=?`,
|
||||||
|
string(uid), promptAt)
|
||||||
|
_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("✅ Invited `%s` to the Space.", uid))
|
||||||
|
slog.Info("space_inviter: invite_attempt", "user", uid, "outcome", "ok")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errStr := err.Error()
|
||||||
|
outcome := "error"
|
||||||
|
if strings.Contains(errStr, "M_FORBIDDEN") {
|
||||||
|
outcome = "forbidden"
|
||||||
|
_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf(
|
||||||
|
"🚨 Tried to invite `%s` but got M_FORBIDDEN.\nMy power level may have dropped — check Space permissions.", uid))
|
||||||
|
} else {
|
||||||
|
_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("⚠️ Invite failed for `%s`: %s", uid, errStr))
|
||||||
|
}
|
||||||
|
_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome=?, invite_error=? WHERE user_id=? AND prompt_sent_at=?`,
|
||||||
|
outcome, errStr, string(uid), promptAt)
|
||||||
|
slog.Error("space_inviter: invite_attempt", "user", uid, "outcome", outcome, "err", errStr)
|
||||||
|
}
|
||||||
9
main.go
9
main.go
@@ -191,6 +191,10 @@ func main() {
|
|||||||
registry.Register(plugin.NewShadePlugin(client))
|
registry.Register(plugin.NewShadePlugin(client))
|
||||||
registry.Register(plugin.NewWelcomePlugin(client, xpPlugin, registry))
|
registry.Register(plugin.NewWelcomePlugin(client, xpPlugin, registry))
|
||||||
|
|
||||||
|
// Space inviter (admin-prompted invites for Space-less local users)
|
||||||
|
spaceInviter := plugin.NewSpaceInviterPlugin(client)
|
||||||
|
registry.Register(spaceInviter)
|
||||||
|
|
||||||
// Initialize all plugins
|
// Initialize all plugins
|
||||||
if err := registry.Init(); err != nil {
|
if err := registry.Init(); err != nil {
|
||||||
slog.Error("plugin init failed", "err", err)
|
slog.Error("plugin init failed", "err", err)
|
||||||
@@ -234,6 +238,11 @@ func main() {
|
|||||||
// Track join/leave/invite for moderation
|
// Track join/leave/invite for moderation
|
||||||
targetUser := id.UserID(evt.GetStateKey())
|
targetUser := id.UserID(evt.GetStateKey())
|
||||||
modPlugin.OnMemberEvent(evt.RoomID, targetUser, mem.Membership)
|
modPlugin.OnMemberEvent(evt.RoomID, targetUser, mem.Membership)
|
||||||
|
|
||||||
|
// Space inviter: react to local-user joins
|
||||||
|
if mem.Membership == event.MembershipJoin {
|
||||||
|
spaceInviter.OnMemberJoin(targetUser)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Message handler
|
// Message handler
|
||||||
|
|||||||
239
space_inviter_spec.md
Normal file
239
space_inviter_spec.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Spec: Space Inviter — `space_inviter.go`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a local homeserver user is detected as not being a member of **any** Matrix Space
|
||||||
|
(i.e. they aren't in the target Space *and* aren't in any other Space the bot can see them in),
|
||||||
|
the bot should **DM the admin user** and ask whether to invite them to the target Space,
|
||||||
|
rather than auto-inviting.
|
||||||
|
|
||||||
|
The intent: only prompt for users who are completely Space-less. If they're already in some
|
||||||
|
other Space, leave them alone — they've made a choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config values required
|
||||||
|
|
||||||
|
All values sourced from the existing GogoBee config struct. No hardcoding.
|
||||||
|
|
||||||
|
| Key | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `target_space_id` | `id.RoomID` | The Space to potentially invite users into |
|
||||||
|
| `home_domain` | `string` | e.g. `parodia.dev` — used to filter out federated users |
|
||||||
|
| `synapse_base_url` | `string` | e.g. `https://parodia.dev` — for admin API calls |
|
||||||
|
| `admin_access_token` | `string` | Synapse admin token — store in secrets |
|
||||||
|
| `admin_user_id` | `id.UserID` | The user to DM with invite prompts (i.e. the operator) |
|
||||||
|
| `invite_delay` | `time.Duration` | Delay between prompts during startup sweep (default: 2s) |
|
||||||
|
| `reprompt_cooldown` | `time.Duration` | Minimum time between prompts for the same user (default: 30 days). Prevents restart/upgrade ping-spam for users who already said `no`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trigger conditions
|
||||||
|
|
||||||
|
### 1. Startup sweep
|
||||||
|
- On bot startup, after permission checks pass, fetch all local non-guest non-deactivated users via the Synapse Admin API (`/_synapse/admin/v2/users`)
|
||||||
|
- Pass `?guests=false&deactivated=false` and paginate fully
|
||||||
|
- Apply the user filter (see "User filter" below) before any Space-membership work
|
||||||
|
- For each remaining user, determine whether they belong to **any** Space (see "Space membership check" below)
|
||||||
|
- For each user who is in zero Spaces and not on the blocklist and has no pending invite to `target_space_id`: queue a DM prompt to the admin user
|
||||||
|
- Stagger prompts using `invite_delay` to avoid flooding the admin DM
|
||||||
|
|
||||||
|
### 2. Reactive handler
|
||||||
|
- Listen for `m.room.member` events with `membership: join`
|
||||||
|
- Apply the user filter (see "User filter" below)
|
||||||
|
- If the joining user is in zero Spaces, not on the blocklist, has no pending invite to `target_space_id`, and a prompt hasn't already been sent for them this session: send a DM prompt to the admin user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User filter
|
||||||
|
|
||||||
|
A user is eligible for a Space-less prompt only if **all** of the following hold. Apply this
|
||||||
|
filter in both the startup sweep and the reactive handler — same rules, one place in code.
|
||||||
|
|
||||||
|
1. Local to this homeserver (user ID suffix matches `:home_domain`). Federated users are out of scope.
|
||||||
|
2. Not the bot itself.
|
||||||
|
3. Not the configured `admin_user_id`.
|
||||||
|
4. Not an appservice / bridge puppet. Synapse `/admin/v2/users` returns a `user_type` field — exclude entries where `user_type` is non-empty (typical values: `bot`, `support`, and appservice puppets often appear with non-empty types or with names matching the appservice's `sender_localpart`). Also exclude users whose localpart matches any configured appservice `sender_localpart` if available.
|
||||||
|
5. Not a guest, not deactivated (already filtered at the API level, but re-check defensively for the reactive path).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Space membership check
|
||||||
|
|
||||||
|
**Operational invariant: the bot is joined to every Space on the homeserver.** The spec
|
||||||
|
relies on this — if a Space exists that the bot isn't in, users in only that Space will be
|
||||||
|
incorrectly flagged as Space-less.
|
||||||
|
|
||||||
|
A user counts as "in a Space" if they are joined or invited to **any** room of type `m.space`
|
||||||
|
(i.e. `m.room.create` content has `"type": "m.space"`).
|
||||||
|
|
||||||
|
- On startup (and refreshed as the bot joins/leaves Spaces), enumerate the bot's joined rooms via the mautrix client and filter to those with `m.room.create.type == m.space` — this yields the set of Spaces to check against
|
||||||
|
- For each Space, fetch its members via `JoinedMembers` (joined) and the room state for `m.room.member` events with `membership: invite` (pending invites)
|
||||||
|
- Build an in-memory union set: `users_in_any_space = ⋃ members(space)` for all known Spaces
|
||||||
|
- A local user is "Space-less" iff they are absent from this union set
|
||||||
|
- Refresh the union set at the start of each sweep; for the reactive handler, update incrementally on `m.room.member` events in Space rooms
|
||||||
|
|
||||||
|
No admin API call is needed for the Space-membership check itself. The admin API is still
|
||||||
|
required to enumerate local users (to discover users who are in zero rooms the bot can see).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DM room bootstrap
|
||||||
|
|
||||||
|
Before sending any prompt, the bot must have a 1:1 DM room with `admin_user_id`.
|
||||||
|
|
||||||
|
- On startup, look up the bot's `m.direct` account data
|
||||||
|
- If an existing DM room with `admin_user_id` is listed and the bot is still joined to it, reuse it
|
||||||
|
- Otherwise create a new room: invite-only, `is_direct: true`, invite `admin_user_id`, and update `m.direct` account data to record the mapping
|
||||||
|
- Cache the resolved DM room ID in memory for the session
|
||||||
|
- If the admin leaves the DM room, treat the next prompt as a trigger to recreate it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DM prompt format
|
||||||
|
|
||||||
|
Sent as a plain text message to the admin user's DM room:
|
||||||
|
|
||||||
|
```
|
||||||
|
🐝 @someuser:parodia.dev just joined but isn't in the Space.
|
||||||
|
Want me to invite them?
|
||||||
|
|
||||||
|
Reply with:
|
||||||
|
yes — invite them
|
||||||
|
no — skip for now (I'll ask again later)
|
||||||
|
ignore — never ask about this user again
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the existing GogoBee pattern for interactive admin responses (reply matching or
|
||||||
|
reaction handling — match whatever pattern is already established in the codebase).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response handling
|
||||||
|
|
||||||
|
| Reply | Action |
|
||||||
|
|---|---|
|
||||||
|
| `yes` (case-insensitive) | Invite the user to the Space identified by `target_space_id` (always — never any other Space), log success or failure |
|
||||||
|
| `no` (case-insensitive) | Skip. Will prompt again on next sweep or next join event |
|
||||||
|
| `ignore` (case-insensitive) | Add user to persistent blocklist, never prompt again |
|
||||||
|
|
||||||
|
- Only one pending prompt per user at a time — deduplication required
|
||||||
|
- If the admin doesn't respond, do nothing; prompt again on next trigger
|
||||||
|
- **Re-validate at reply time, not prompt time.** When the admin replies `yes`, re-check that the user is still Space-less and still has no pending invite to `target_space_id`. If they joined a Space (or were already invited) between prompt and reply, skip the invite and DM the admin a one-liner: `ℹ️ @user:domain is already in a Space — skipped.`
|
||||||
|
- **Unknown replies are silently ignored.** If the admin types anything other than `yes`/`no`/`ignore` (case-insensitive, leading/trailing whitespace stripped) in the DM room while a prompt is pending, the bot does not respond, does not error, does not re-send help. The pending prompt remains pending until a valid reply arrives or the bot restarts.
|
||||||
|
- If the user already has a pending invite to `target_space_id` at reply time (e.g. someone else invited them out-of-band), treat `yes` as a no-op and log it.
|
||||||
|
- If an invite attempt fails with `M_FORBIDDEN`, log an error loudly and DM the admin:
|
||||||
|
> 🚨 Tried to invite @someuser:parodia.dev but got M_FORBIDDEN.
|
||||||
|
> My power level may have dropped. Check the Space permissions.
|
||||||
|
- For any other invite failure (rate-limit, network, etc.), log the full error and DM the admin a short notice; do not auto-retry — the next sweep will catch it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permission check (on startup, before sweep)
|
||||||
|
|
||||||
|
Abort with a fatal error and a clear message if any of the following are true:
|
||||||
|
|
||||||
|
1. Bot is not joined to `target_space_id` or cannot read its membership
|
||||||
|
2. Bot cannot read the target Space's power levels
|
||||||
|
3. Bot's power level in the target Space is below the `invite` threshold
|
||||||
|
4. `admin_access_token` is empty
|
||||||
|
5. Admin API returns non-200 (log status code and response body)
|
||||||
|
6. **Sanity check**: log a warning (not fatal) listing every Space the bot is currently joined to, so the operator can eyeball whether any Space is missing. If the bot is in zero Spaces other than the target, log a louder warning — the "in any Space" check will degenerate to "in target Space".
|
||||||
|
|
||||||
|
Error messages should be specific about what's wrong and what needs to be fixed.
|
||||||
|
Do not silently swallow these — they should halt startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence — `space_inviter_prompts` table
|
||||||
|
|
||||||
|
All prompt activity is persisted in a single SQLite table. This replaces both the
|
||||||
|
flat-file blocklist and the in-memory dedup set with one durable source of truth.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS space_inviter_prompts (
|
||||||
|
user_id TEXT NOT NULL, -- @user:domain
|
||||||
|
prompt_sent_at INTEGER NOT NULL, -- unix seconds
|
||||||
|
trigger_kind TEXT NOT NULL, -- 'sweep' | 'reactive'
|
||||||
|
dm_room_id TEXT NOT NULL, -- room the prompt was sent in
|
||||||
|
dm_event_id TEXT NOT NULL, -- event ID of the prompt message
|
||||||
|
reply TEXT, -- 'yes' | 'no' | 'ignore' | NULL (pending)
|
||||||
|
replied_at INTEGER, -- unix seconds, NULL if pending
|
||||||
|
invite_attempted INTEGER NOT NULL DEFAULT 0, -- 0/1
|
||||||
|
invite_outcome TEXT, -- 'ok' | 'forbidden' | 'error' | 'skipped_race' | NULL
|
||||||
|
invite_error TEXT, -- raw error string for diagnostics
|
||||||
|
PRIMARY KEY (user_id, prompt_sent_at)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_space_inviter_prompts_user ON space_inviter_prompts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_space_inviter_prompts_pending ON space_inviter_prompts(user_id) WHERE reply IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
- **On prompt sent**: insert a new row with `reply = NULL`.
|
||||||
|
- **On reply received**: update the most-recent pending row for that user — set `reply`, `replied_at`. Match the reply to the pending prompt using the `m.relates_to` reply reference to `dm_event_id` if the admin uses Matrix replies; otherwise just match the most-recent pending row in the DM.
|
||||||
|
- **On invite attempt**: update the same row — set `invite_attempted = 1`, `invite_outcome`, and `invite_error` if applicable.
|
||||||
|
|
||||||
|
### Querying
|
||||||
|
|
||||||
|
- **Is user blocked (`ignore`)?** `SELECT 1 FROM space_inviter_prompts WHERE user_id = ? AND reply = 'ignore' LIMIT 1`
|
||||||
|
- **Has a pending prompt?** `SELECT 1 FROM space_inviter_prompts WHERE user_id = ? AND reply IS NULL LIMIT 1`
|
||||||
|
- **Has been prompted this session?** Replaced by "has any row at all" — but the eligibility check is "no pending prompt AND not on ignore list AND (not yet ever prompted OR last reply was `no` and we want to re-prompt on next trigger)". `no` replies don't block re-prompting.
|
||||||
|
|
||||||
|
### Dedup rules (in DB terms)
|
||||||
|
|
||||||
|
A user is **not eligible** for a new prompt if any of these are true:
|
||||||
|
|
||||||
|
1. A pending row exists (`reply IS NULL`) — already awaiting an answer.
|
||||||
|
2. Any row exists with `reply = 'ignore'` — permanently silenced.
|
||||||
|
3. **Cooldown**: any row exists with `prompt_sent_at > (now - reprompt_cooldown)` — already prompted recently, regardless of reply. This is the rule that prevents restart/upgrade ping-spam: a `no` from yesterday does not get re-asked tomorrow just because the bot restarted.
|
||||||
|
|
||||||
|
A `no` reply does eventually allow re-prompting, but only after `reprompt_cooldown` has elapsed since the last prompt. Default 30 days — long enough that the admin won't feel nagged across normal ops, short enough that genuinely new context (user has been Space-less for a month) gets surfaced.
|
||||||
|
|
||||||
|
### Eligibility query
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Returns 1 row if user IS eligible for a new prompt
|
||||||
|
SELECT 1
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM space_inviter_prompts
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (
|
||||||
|
reply IS NULL -- pending
|
||||||
|
OR reply = 'ignore' -- blocked forever
|
||||||
|
OR prompt_sent_at > ? -- within cooldown window (now - reprompt_cooldown)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Un-ignore
|
||||||
|
|
||||||
|
- `UPDATE space_inviter_prompts SET reply = 'no' WHERE user_id = ? AND reply = 'ignore'` via a sqlite shell, or a small admin command. v1 documents the SQL one-liner; in-band command can come later.
|
||||||
|
|
||||||
|
### Schema migration
|
||||||
|
|
||||||
|
- Apply via the existing GogoBee migration mechanism. New table, no data migration from any prior store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
At INFO level (one structured log line per event):
|
||||||
|
|
||||||
|
- Startup sweep begin/end with counts: total local users, eligible after filter, Space-less, prompts queued
|
||||||
|
- Each prompt sent: `event=prompt_sent user=@x:domain trigger=sweep|reactive`
|
||||||
|
- Each reply received: `event=prompt_reply user=@x:domain reply=yes|no|ignore|unknown`
|
||||||
|
- Each invite attempt: `event=invite_attempt user=@x:domain space=<target> outcome=ok|forbidden|error err=...`
|
||||||
|
- DM room bootstrap: `event=dm_room_resolved room=<id> created=true|false`
|
||||||
|
|
||||||
|
At WARN level: skipped invites due to race (user joined a Space between prompt and reply), invite failures other than M_FORBIDDEN.
|
||||||
|
|
||||||
|
At ERROR level: M_FORBIDDEN on invite, admin API non-200, fatal startup permission failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Auto-accepting join requests
|
||||||
|
- Inviting federated users from other homeservers
|
||||||
|
- Any UI beyond DM text messages
|
||||||
Reference in New Issue
Block a user