feat: simplify mail wisps - single DB with --wisp flag (gt-fgms)
Removed dual-routing architecture that used separate .beads-wisp/ directory. Now uses single .beads/ with --wisp flag passed to bd create. Changes: - router.go: Remove resolveWispDir(), simplify shouldBeWisp() - mailbox.go: Remove wispDir field and dual-source query logic - types.go: Rename Ephemeral to Wisp, remove MessageSource - mail.go: Rename --ephemeral to --wisp flag - spawn.go: Use Wisp field for lifecycle messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ var (
|
|||||||
mailPriority int
|
mailPriority int
|
||||||
mailUrgent bool
|
mailUrgent bool
|
||||||
mailPinned bool
|
mailPinned bool
|
||||||
mailEphemeral bool
|
mailWisp bool
|
||||||
mailType string
|
mailType string
|
||||||
mailReplyTo string
|
mailReplyTo string
|
||||||
mailNotify bool
|
mailNotify bool
|
||||||
@@ -236,7 +236,7 @@ func init() {
|
|||||||
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
|
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
|
||||||
mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
|
mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
|
||||||
mailSendCmd.Flags().BoolVar(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)")
|
mailSendCmd.Flags().BoolVar(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)")
|
||||||
mailSendCmd.Flags().BoolVar(&mailEphemeral, "ephemeral", false, "Send as ephemeral wisp (auto-cleanup on patrol squash)")
|
mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", false, "Send as wisp (ephemeral, auto-cleanup on patrol squash)")
|
||||||
mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)")
|
mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)")
|
||||||
_ = mailSendCmd.MarkFlagRequired("subject")
|
_ = mailSendCmd.MarkFlagRequired("subject")
|
||||||
|
|
||||||
@@ -334,8 +334,8 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
|||||||
// Set pinned flag
|
// Set pinned flag
|
||||||
msg.Pinned = mailPinned
|
msg.Pinned = mailPinned
|
||||||
|
|
||||||
// Set ephemeral flag
|
// Set wisp flag (ephemeral message)
|
||||||
msg.Ephemeral = mailEphemeral
|
msg.Wisp = mailWisp
|
||||||
|
|
||||||
// Handle reply-to: auto-set type to reply and look up thread
|
// Handle reply-to: auto-set type to reply and look up thread
|
||||||
if mailReplyTo != "" {
|
if mailReplyTo != "" {
|
||||||
@@ -439,12 +439,12 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
|
|||||||
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||||
priorityMarker = " " + style.Bold.Render("!")
|
priorityMarker = " " + style.Bold.Render("!")
|
||||||
}
|
}
|
||||||
ephemeralMarker := ""
|
wispMarker := ""
|
||||||
if msg.Ephemeral || msg.Source == mail.SourceWisp {
|
if msg.Wisp {
|
||||||
ephemeralMarker = " " + style.Dim.Render("(ephemeral)")
|
wispMarker = " " + style.Dim.Render("(wisp)")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, ephemeralMarker)
|
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
|
||||||
fmt.Printf(" %s from %s\n",
|
fmt.Printf(" %s from %s\n",
|
||||||
style.Dim.Render(msg.ID),
|
style.Dim.Render(msg.ID),
|
||||||
msg.From)
|
msg.From)
|
||||||
|
|||||||
@@ -398,11 +398,11 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
// Notify Witness with POLECAT_STARTED message (ephemeral - lifecycle ping)
|
// Notify Witness with POLECAT_STARTED message (ephemeral - lifecycle ping)
|
||||||
witnessAddr := fmt.Sprintf("%s/witness", rigName)
|
witnessAddr := fmt.Sprintf("%s/witness", rigName)
|
||||||
witnessNotification := &mail.Message{
|
witnessNotification := &mail.Message{
|
||||||
To: witnessAddr,
|
To: witnessAddr,
|
||||||
From: sender,
|
From: sender,
|
||||||
Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName),
|
Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName),
|
||||||
Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName),
|
Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName),
|
||||||
Ephemeral: true,
|
Wisp: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := townRouter.Send(witnessNotification); err != nil {
|
if err := townRouter.Send(witnessNotification); err != nil {
|
||||||
@@ -414,11 +414,11 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
// Notify Deacon with POLECAT_STARTED message (ephemeral - lifecycle ping)
|
// Notify Deacon with POLECAT_STARTED message (ephemeral - lifecycle ping)
|
||||||
deaconAddr := "deacon/"
|
deaconAddr := "deacon/"
|
||||||
deaconNotification := &mail.Message{
|
deaconNotification := &mail.Message{
|
||||||
To: deaconAddr,
|
To: deaconAddr,
|
||||||
From: sender,
|
From: sender,
|
||||||
Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName),
|
Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName),
|
||||||
Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName),
|
Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName),
|
||||||
Ephemeral: true,
|
Wisp: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := townRouter.Send(deaconNotification); err != nil {
|
if err := townRouter.Send(deaconNotification); err != nil {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ type Mailbox struct {
|
|||||||
identity string // beads identity (e.g., "gastown/polecats/Toast")
|
identity string // beads identity (e.g., "gastown/polecats/Toast")
|
||||||
workDir string // directory to run bd commands in
|
workDir string // directory to run bd commands in
|
||||||
beadsDir string // explicit .beads directory path (set via BEADS_DIR)
|
beadsDir string // explicit .beads directory path (set via BEADS_DIR)
|
||||||
wispDir string // .beads-wisp directory for ephemeral messages
|
|
||||||
path string // for legacy JSONL mode (crew workers)
|
path string // for legacy JSONL mode (crew workers)
|
||||||
legacy bool // true = use JSONL files, false = use beads
|
legacy bool // true = use JSONL files, false = use beads
|
||||||
}
|
}
|
||||||
@@ -49,26 +48,20 @@ func NewMailboxBeads(identity, workDir string) *Mailbox {
|
|||||||
// NewMailboxFromAddress creates a beads-backed mailbox from a GGT address.
|
// NewMailboxFromAddress creates a beads-backed mailbox from a GGT address.
|
||||||
func NewMailboxFromAddress(address, workDir string) *Mailbox {
|
func NewMailboxFromAddress(address, workDir string) *Mailbox {
|
||||||
beadsDir := filepath.Join(workDir, ".beads")
|
beadsDir := filepath.Join(workDir, ".beads")
|
||||||
// Wisp directory is .beads-wisp/.beads (bd init creates .beads/ subdirectory)
|
|
||||||
wispDir := filepath.Join(workDir, ".beads-wisp", ".beads")
|
|
||||||
return &Mailbox{
|
return &Mailbox{
|
||||||
identity: addressToIdentity(address),
|
identity: addressToIdentity(address),
|
||||||
workDir: workDir,
|
workDir: workDir,
|
||||||
beadsDir: beadsDir,
|
beadsDir: beadsDir,
|
||||||
wispDir: wispDir,
|
|
||||||
legacy: false,
|
legacy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMailboxWithBeadsDir creates a mailbox with an explicit beads directory.
|
// NewMailboxWithBeadsDir creates a mailbox with an explicit beads directory.
|
||||||
func NewMailboxWithBeadsDir(address, workDir, beadsDir string) *Mailbox {
|
func NewMailboxWithBeadsDir(address, workDir, beadsDir string) *Mailbox {
|
||||||
// Derive wispDir from beadsDir (.beads-wisp/.beads sibling structure)
|
|
||||||
wispDir := filepath.Join(filepath.Dir(beadsDir), ".beads-wisp", ".beads")
|
|
||||||
return &Mailbox{
|
return &Mailbox{
|
||||||
identity: addressToIdentity(address),
|
identity: addressToIdentity(address),
|
||||||
workDir: workDir,
|
workDir: workDir,
|
||||||
beadsDir: beadsDir,
|
beadsDir: beadsDir,
|
||||||
wispDir: wispDir,
|
|
||||||
legacy: false,
|
legacy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,29 +85,23 @@ func (m *Mailbox) List() ([]*Message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailbox) listBeads() ([]*Message, error) {
|
func (m *Mailbox) listBeads() ([]*Message, error) {
|
||||||
// Query persistent beads
|
// Single query to beads - returns both persistent and wisp messages
|
||||||
persistentMsgs, err := m.listFromDir(m.beadsDir, SourcePersistent)
|
// Wisps are stored in same DB with wisp=true flag, filtered from JSONL export
|
||||||
|
messages, err := m.listFromDir(m.beadsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query wisp beads (ignore errors for missing dir)
|
// Sort by timestamp (newest first)
|
||||||
var wispMsgs []*Message
|
sort.Slice(messages, func(i, j int) bool {
|
||||||
if m.wispDir != "" {
|
return messages[i].Timestamp.After(messages[j].Timestamp)
|
||||||
wispMsgs, _ = m.listFromDir(m.wispDir, SourceWisp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge and sort by timestamp (newest first)
|
|
||||||
all := append(persistentMsgs, wispMsgs...)
|
|
||||||
sort.Slice(all, func(i, j int) bool {
|
|
||||||
return all[i].Timestamp.After(all[j].Timestamp)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return all, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listFromDir queries messages from a specific beads directory.
|
// listFromDir queries messages from a beads directory.
|
||||||
func (m *Mailbox) listFromDir(beadsDir string, source MessageSource) ([]*Message, error) {
|
func (m *Mailbox) listFromDir(beadsDir string) ([]*Message, error) {
|
||||||
// bd list --type=message --assignee=<identity> --json --status=open
|
// bd list --type=message --assignee=<identity> --json --status=open
|
||||||
cmd := exec.Command("bd", "list",
|
cmd := exec.Command("bd", "list",
|
||||||
"--type", "message",
|
"--type", "message",
|
||||||
@@ -149,13 +136,10 @@ func (m *Mailbox) listFromDir(beadsDir string, source MessageSource) ([]*Message
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to GGT messages and set source
|
// Convert to GGT messages - wisp status comes from beads issue.wisp field
|
||||||
var messages []*Message
|
var messages []*Message
|
||||||
for _, bm := range beadsMsgs {
|
for _, bm := range beadsMsgs {
|
||||||
msg := bm.ToMessage()
|
messages = append(messages, bm.ToMessage())
|
||||||
msg.Source = source
|
|
||||||
msg.Ephemeral = (source == SourceWisp)
|
|
||||||
messages = append(messages, msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
@@ -226,25 +210,12 @@ func (m *Mailbox) Get(id string) (*Message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailbox) getBeads(id string) (*Message, error) {
|
func (m *Mailbox) getBeads(id string) (*Message, error) {
|
||||||
// Try persistent first
|
// Single DB query - wisps and persistent messages in same store
|
||||||
msg, err := m.getFromDir(id, m.beadsDir, SourcePersistent)
|
return m.getFromDir(id, m.beadsDir)
|
||||||
if err == nil {
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try wisp storage
|
|
||||||
if m.wispDir != "" {
|
|
||||||
msg, err = m.getFromDir(id, m.wispDir, SourceWisp)
|
|
||||||
if err == nil {
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ErrMessageNotFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFromDir retrieves a message from a specific beads directory.
|
// getFromDir retrieves a message from a beads directory.
|
||||||
func (m *Mailbox) getFromDir(id, beadsDir string, source MessageSource) (*Message, error) {
|
func (m *Mailbox) getFromDir(id, beadsDir string) (*Message, error) {
|
||||||
cmd := exec.Command("bd", "show", id, "--json")
|
cmd := exec.Command("bd", "show", id, "--json")
|
||||||
cmd.Dir = m.workDir
|
cmd.Dir = m.workDir
|
||||||
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir)
|
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir)
|
||||||
@@ -273,10 +244,8 @@ func (m *Mailbox) getFromDir(id, beadsDir string, source MessageSource) (*Messag
|
|||||||
return nil, ErrMessageNotFound
|
return nil, ErrMessageNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := bms[0].ToMessage()
|
// Wisp status comes from beads issue.wisp field via ToMessage()
|
||||||
msg.Source = source
|
return bms[0].ToMessage(), nil
|
||||||
msg.Ephemeral = (source == SourceWisp)
|
|
||||||
return msg, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailbox) getLegacy(id string) (*Message, error) {
|
func (m *Mailbox) getLegacy(id string) (*Message, error) {
|
||||||
@@ -301,21 +270,8 @@ func (m *Mailbox) MarkRead(id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailbox) markReadBeads(id string) error {
|
func (m *Mailbox) markReadBeads(id string) error {
|
||||||
// Try persistent first
|
// Single DB - wisps and persistent messages in same store
|
||||||
err := m.closeInDir(id, m.beadsDir)
|
return m.closeInDir(id, m.beadsDir)
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try wisp storage
|
|
||||||
if m.wispDir != "" {
|
|
||||||
err = m.closeInDir(id, m.wispDir)
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrMessageNotFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeInDir closes a message in a specific beads directory.
|
// closeInDir closes a message in a specific beads directory.
|
||||||
|
|||||||
@@ -88,33 +88,23 @@ func isTownLevelAddress(address string) bool {
|
|||||||
return addr == "mayor" || addr == "deacon"
|
return addr == "mayor" || addr == "deacon"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveWispDir returns the .beads-wisp/.beads directory for ephemeral mail.
|
// shouldBeWisp determines if a message should be stored as a wisp.
|
||||||
// Like resolveBeadsDir, mail wisps use town-level storage.
|
|
||||||
// Note: bd init creates .beads/ subdirectory, so the full path is .beads-wisp/.beads
|
|
||||||
func (r *Router) resolveWispDir() string {
|
|
||||||
if r.townRoot == "" {
|
|
||||||
return filepath.Join(r.workDir, ".beads-wisp", ".beads")
|
|
||||||
}
|
|
||||||
return filepath.Join(r.townRoot, ".beads-wisp", ".beads")
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldBeEphemeral determines if a message should be stored as a wisp.
|
|
||||||
// Returns true if:
|
// Returns true if:
|
||||||
// - Message.Ephemeral is explicitly set
|
// - Message.Wisp is explicitly set
|
||||||
// - Subject matches lifecycle message patterns (POLECAT_*, NUDGE, etc.)
|
// - Subject matches lifecycle message patterns (POLECAT_*, NUDGE, etc.)
|
||||||
func (r *Router) shouldBeEphemeral(msg *Message) bool {
|
func (r *Router) shouldBeWisp(msg *Message) bool {
|
||||||
if msg.Ephemeral {
|
if msg.Wisp {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Auto-detect lifecycle messages by subject prefix
|
// Auto-detect lifecycle messages by subject prefix
|
||||||
subjectLower := strings.ToLower(msg.Subject)
|
subjectLower := strings.ToLower(msg.Subject)
|
||||||
ephemeralPrefixes := []string{
|
wispPrefixes := []string{
|
||||||
"polecat_started",
|
"polecat_started",
|
||||||
"polecat_done",
|
"polecat_done",
|
||||||
"start_work",
|
"start_work",
|
||||||
"nudge",
|
"nudge",
|
||||||
}
|
}
|
||||||
for _, prefix := range ephemeralPrefixes {
|
for _, prefix := range wispPrefixes {
|
||||||
if strings.HasPrefix(subjectLower, prefix) {
|
if strings.HasPrefix(subjectLower, prefix) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -154,18 +144,12 @@ func (r *Router) Send(msg *Message) error {
|
|||||||
args = append(args, "--labels", strings.Join(labels, ","))
|
args = append(args, "--labels", strings.Join(labels, ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the correct beads directory based on ephemeral status
|
// Add --wisp flag for ephemeral messages (stored in single DB, filtered from JSONL export)
|
||||||
var beadsDir string
|
if r.shouldBeWisp(msg) {
|
||||||
if r.shouldBeEphemeral(msg) {
|
args = append(args, "--wisp")
|
||||||
beadsDir = r.resolveWispDir()
|
|
||||||
// Ensure wisp directory exists
|
|
||||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating wisp dir: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
beadsDir = r.resolveBeadsDir(msg.To)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beadsDir := r.resolveBeadsDir(msg.To)
|
||||||
cmd := exec.Command("bd", args...)
|
cmd := exec.Command("bd", args...)
|
||||||
cmd.Env = append(cmd.Environ(),
|
cmd.Env = append(cmd.Environ(),
|
||||||
"BEADS_DIR="+beadsDir,
|
"BEADS_DIR="+beadsDir,
|
||||||
|
|||||||
@@ -28,16 +28,6 @@ const (
|
|||||||
// MessageType indicates the purpose of a message.
|
// MessageType indicates the purpose of a message.
|
||||||
type MessageType string
|
type MessageType string
|
||||||
|
|
||||||
// MessageSource indicates where a message is stored.
|
|
||||||
type MessageSource string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// SourcePersistent indicates the message is in permanent .beads storage.
|
|
||||||
SourcePersistent MessageSource = "persistent"
|
|
||||||
|
|
||||||
// SourceWisp indicates the message is in ephemeral .beads-wisp storage.
|
|
||||||
SourceWisp MessageSource = "wisp"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// TypeTask indicates a message requiring action from the recipient.
|
// TypeTask indicates a message requiring action from the recipient.
|
||||||
@@ -109,13 +99,9 @@ type Message struct {
|
|||||||
// Pinned marks the message as pinned (won't be auto-archived).
|
// Pinned marks the message as pinned (won't be auto-archived).
|
||||||
Pinned bool `json:"pinned,omitempty"`
|
Pinned bool `json:"pinned,omitempty"`
|
||||||
|
|
||||||
// Ephemeral marks this as a transient message stored in wisps.
|
// Wisp marks this as a transient message (stored in same DB but filtered from JSONL export).
|
||||||
// Ephemeral messages auto-cleanup on patrol squash.
|
// Wisp messages auto-cleanup on patrol squash.
|
||||||
Ephemeral bool `json:"ephemeral,omitempty"`
|
Wisp bool `json:"wisp,omitempty"`
|
||||||
|
|
||||||
// Source indicates where this message is stored (persistent or wisp).
|
|
||||||
// Set during List(), not serialized.
|
|
||||||
Source MessageSource `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessage creates a new message with a generated ID and thread ID.
|
// NewMessage creates a new message with a generated ID and thread ID.
|
||||||
@@ -177,6 +163,7 @@ type BeadsMessage struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Labels []string `json:"labels"` // Metadata labels (from:X, thread:X, reply-to:X, msg-type:X)
|
Labels []string `json:"labels"` // Metadata labels (from:X, thread:X, reply-to:X, msg-type:X)
|
||||||
Pinned bool `json:"pinned,omitempty"`
|
Pinned bool `json:"pinned,omitempty"`
|
||||||
|
Wisp bool `json:"wisp,omitempty"` // Ephemeral message (filtered from JSONL export)
|
||||||
|
|
||||||
// Cached parsed values (populated by ParseLabels)
|
// Cached parsed values (populated by ParseLabels)
|
||||||
sender string
|
sender string
|
||||||
@@ -237,6 +224,7 @@ func (bm *BeadsMessage) ToMessage() *Message {
|
|||||||
Type: msgType,
|
Type: msgType,
|
||||||
ThreadID: bm.threadID,
|
ThreadID: bm.threadID,
|
||||||
ReplyTo: bm.replyTo,
|
ReplyTo: bm.replyTo,
|
||||||
|
Wisp: bm.Wisp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user