mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Headless real-character sim + new-feature exercise harness
Run the actual Adventure module against a copy of the prod DB with no Matrix client, to smoke-test before deploy. - expedition-sim: -real-user @mxid runs an EXISTING character loaded from -data's gogobee.db instead of a synthetic build. SimRunner gains PrepareRealCharacter (heals to full + tops up bankroll; keeps real race/class/subclass/level/gear/spells). - plugin.SendReply now honors the MessageSink like SendMessage/SendDM. Reply-based handlers (duels, !town, !rivals, !achievements) previously bypassed the capture seam and hit a nil client under the sink. Prod behavior is unchanged (sink is nil in production). - exercise_prod_test.go (build tag: prodexercise) drives every N-series feature — world boss, duels, Shadow, Renown, achievements, journal, town registries, vault, gifting — against a prod DB copy with all outbound messages captured. Gated on GOGOBEE_PROD_DB_DIR; never runs in normal CI. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -46,6 +46,8 @@ func main() {
|
|||||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||||
|
|
||||||
|
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||||
|
|
||||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||||
@@ -90,9 +92,49 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *realUser != "" {
|
||||||
|
if *dataDir == "" {
|
||||||
|
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
|
||||||
|
}
|
||||||
|
if *party != 1 {
|
||||||
|
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
|
||||||
|
}
|
||||||
|
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||||
|
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
|
||||||
|
// migrations against it, and the run mutates HP/coin/inventory. Never point this
|
||||||
|
// at the live prod file or at ./data.
|
||||||
|
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||||
|
runner, err := plugin.NewSimRunner(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
fail("init runner:", err)
|
||||||
|
}
|
||||||
|
defer runner.Close()
|
||||||
|
|
||||||
|
uid := id.UserID(userTag)
|
||||||
|
c, err := runner.PrepareRealCharacter(uid, bank)
|
||||||
|
if err != nil {
|
||||||
|
fail("prepare real character:", err)
|
||||||
|
}
|
||||||
|
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
|
||||||
|
if res != nil {
|
||||||
|
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
|
||||||
|
if !includeLog {
|
||||||
|
res.Log = nil
|
||||||
|
}
|
||||||
|
emitIndented(res)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail("run:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// followerClasses expands -party / -party-classes into the class of each
|
// followerClasses expands -party / -party-classes into the class of each
|
||||||
// follower seat. An explicit list must name every seat: a party of 3 whose
|
// follower seat. An explicit list must name every seat: a party of 3 whose
|
||||||
// second follower silently defaulted to the leader's class would quietly
|
// second follower silently defaulted to the leader's class would quietly
|
||||||
|
|||||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//go:build prodexercise
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||||
|
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||||
|
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||||
|
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||||
|
// before opening it, so even the copy is left pristine.
|
||||||
|
//
|
||||||
|
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||||
|
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||||
|
//
|
||||||
|
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||||
|
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||||
|
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||||
|
// error in one is reported and the battery continues.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExerciseNewFeaturesProd(t *testing.T) {
|
||||||
|
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||||
|
if srcDir == "" {
|
||||||
|
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||||
|
t.Skipf("no gogobee.db in %s", srcDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||||
|
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(tmp); err != nil {
|
||||||
|
t.Fatalf("db.Init: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
|
||||||
|
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||||
|
// so the same DB copy exercises identically regardless of which week we run.
|
||||||
|
simOmenDisabled = true
|
||||||
|
simAutoArmEnabled = true
|
||||||
|
|
||||||
|
euro := NewEuroPlugin(nil)
|
||||||
|
xp := NewXPPlugin(nil)
|
||||||
|
p := NewAdventurePlugin(nil, euro, xp)
|
||||||
|
sink := &captureSink{}
|
||||||
|
p.Sink = sink
|
||||||
|
mark := 0
|
||||||
|
drain := func() {
|
||||||
|
for _, m := range sink.msgs[mark:] {
|
||||||
|
who := string(m.ToUser)
|
||||||
|
if who == "" {
|
||||||
|
who = "room:" + string(m.ToRoom)
|
||||||
|
}
|
||||||
|
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||||
|
}
|
||||||
|
mark = len(sink.msgs)
|
||||||
|
}
|
||||||
|
exercise := func(name string, fn func()) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||||
|
mark = len(sink.msgs)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Logf("──────── %s ────────", name)
|
||||||
|
fn()
|
||||||
|
drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
chars := runnableChars(t)
|
||||||
|
if len(chars) == 0 {
|
||||||
|
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||||
|
}
|
||||||
|
t.Logf("runnable characters: %d", len(chars))
|
||||||
|
for _, c := range chars {
|
||||||
|
healToFull(t, c.uid)
|
||||||
|
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||||
|
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||||
|
}
|
||||||
|
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||||
|
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||||
|
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
xp, _ := loadRenownXP(c.uid)
|
||||||
|
lvl := renownLevelForUser(c.uid)
|
||||||
|
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||||
|
}
|
||||||
|
// Push the first character up a renown level to fire the announce path.
|
||||||
|
c0 := chars[0]
|
||||||
|
before, after, err := addRenownXP(c0.uid, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" addRenownXP: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||||
|
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||||
|
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||||
|
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" spawnWorldBoss: %v", err)
|
||||||
|
}
|
||||||
|
if boss != nil {
|
||||||
|
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||||
|
}
|
||||||
|
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||||
|
for _, c := range chars {
|
||||||
|
t.Logf(" -- %s takes a bout --", c.uid)
|
||||||
|
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||||
|
}
|
||||||
|
t.Logf(" -- pool after the raid --")
|
||||||
|
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||||
|
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||||
|
if len(chars) < 2 {
|
||||||
|
t.Log(" need 2 characters for a duel; skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, b := chars[0], chars[1]
|
||||||
|
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||||
|
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||||
|
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||||
|
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||||
|
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||||
|
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||||
|
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||||
|
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||||
|
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||||
|
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||||
|
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||||
|
if len(chars) < 2 {
|
||||||
|
t.Log(" need 2 characters to gift; skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
giver, receiver := chars[0], chars[1]
|
||||||
|
item := firstInventoryItem(giver.uid)
|
||||||
|
if item == "" {
|
||||||
|
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||||
|
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||||
|
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||||
|
exercise("N7/B4 Achievements — !achievements", func() {
|
||||||
|
ach := NewAchievementsPlugin(nil, nil)
|
||||||
|
ach.Sink = sink
|
||||||
|
p.SetAchievements(ach)
|
||||||
|
for _, c := range chars {
|
||||||
|
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
type exChar struct {
|
||||||
|
uid id.UserID
|
||||||
|
class string
|
||||||
|
level int
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnableChars lists characters that have both a completed dnd_character (class
|
||||||
|
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||||
|
func runnableChars(t *testing.T) []exChar {
|
||||||
|
t.Helper()
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT d.user_id, d.class, d.dnd_level
|
||||||
|
FROM dnd_character d
|
||||||
|
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||||
|
WHERE d.class != ''`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query chars: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []exChar
|
||||||
|
for rows.Next() {
|
||||||
|
var c exChar
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.uid = id.UserID(uid)
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func healToFull(t *testing.T, uid id.UserID) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil || c == nil {
|
||||||
|
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
c.TempHP = 0
|
||||||
|
c.Exhaustion = 0
|
||||||
|
c.ShortRestCharges = c.Level
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstInventoryItem(uid id.UserID) string {
|
||||||
|
d := db.Get()
|
||||||
|
var name string
|
||||||
|
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||||
|
_ = d.QueryRow(
|
||||||
|
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||||
|
string(uid),
|
||||||
|
).Scan(&name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyIfPresent(t *testing.T, src, dst string) {
|
||||||
|
t.Helper()
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return // wal/shm may be absent — fine
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(s string) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
lines[i] = " " + l
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -131,6 +131,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||||
|
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||||
|
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||||
|
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
|
||||||
|
// player rests before heading out, so we don't handicap a wounded prod snapshot
|
||||||
|
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
|
||||||
|
// affords itself regardless of the player's live coin balance. Returns the
|
||||||
|
// loaded character. The DB copy is disposable; the live prod file is untouched.
|
||||||
|
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
|
||||||
|
c, err := LoadDnDCharacter(uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("no character for %s in db copy", uid)
|
||||||
|
}
|
||||||
|
if c.Class == "" {
|
||||||
|
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
|
||||||
|
}
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
c.TempHP = 0
|
||||||
|
c.Exhaustion = 0
|
||||||
|
c.ShortRestCharges = c.Level
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
|
||||||
|
}
|
||||||
|
if bal := s.Euro.GetBalance(uid); bal < bank {
|
||||||
|
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||||
// + a couple offensive items into the synthetic player's inventory so
|
// + a couple offensive items into the synthetic player's inventory so
|
||||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||||
|
|||||||
@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
|||||||
|
|
||||||
// SendReply sends a reply to a specific event.
|
// SendReply sends a reply to a specific event.
|
||||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||||
|
// The sink is the headless capture seam: a reply is an outbound room
|
||||||
|
// message, so — like SendMessage/SendDM — it diverts here when installed and
|
||||||
|
// the live client is never touched. Without this branch, reply-only handlers
|
||||||
|
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
|
||||||
|
if b != nil && b.Sink != nil {
|
||||||
|
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||||
|
return err
|
||||||
|
}
|
||||||
content := textContent(text)
|
content := textContent(text)
|
||||||
if eventID != "" {
|
if eventID != "" {
|
||||||
content.RelatesTo = &event.RelatesTo{
|
content.RelatesTo = &event.RelatesTo{
|
||||||
|
|||||||
Reference in New Issue
Block a user