From 22fd399d7bc3b5f928c3561e6bdd66204f52e8f9 Mon Sep 17 00:00:00 2001 From: Akayashuu Date: Tue, 4 Aug 2026 16:45:18 +0200 Subject: [PATCH] feat(threads): private threads and thread membership Start opens a thread off a message, which is a public trace in the channel. A job an operator wants kept to themselves needs the other shape: a thread created off the channel, typed private, with invitable false so members cannot pull others in. It is invisible until someone is added to it, so AddMember is what makes one usable by a person rather than only by the bot that opened it. --- README.md | 2 +- threads.go | 25 +++++++++++++++++++++++++ threads_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af530c1..44df488 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ msgs, _ := c.Messages().Read(ctx, "", 20, "") // oldest-first | `Guilds()` | `List` `Sole` | | `Members()` | `List` `Get` `Kick` `Ban` | | `Roles()` | `List` `Create` `Update` `Delete` `Assign` `Unassign` | -| `Threads()` | `Start` `CreateForum` `ForumPost` | +| `Threads()` | `Start` `StartPrivate` `AddMember` `CreateForum` `ForumPost` | | `Reactions()` | `Add` `Remove` | | `Permissions()` | `Set` `Remove` | | `Webhooks()` | `Create` `List` `Delete` `Execute` | diff --git a/threads.go b/threads.go index b5a9745..0794b31 100644 --- a/threads.go +++ b/threads.go @@ -24,6 +24,31 @@ func (t *Threads) Start(ctx context.Context, channelID, messageID, name string) return &ch, nil } +// StartPrivate opens a private thread in channelID, with no parent message so +// the channel shows no trace of it. Only invited members and moderators holding +// MANAGE_THREADS can see it — invitable is false, so members cannot pull others +// in. The creating bot is a member; add the humans with AddMember. +func (t *Threads) StartPrivate(ctx context.Context, channelID, name string) (*Channel, error) { + var ch Channel + if err := t.rt.Do(ctx, http.MethodPost, "/channels/"+seg(channelID)+"/threads", + map[string]any{ + "name": name, + "type": ChannelPrivateThread, + "invitable": false, + "auto_archive_duration": autoArchive, + }, &ch); err != nil { + return nil, err + } + return &ch, nil +} + +// AddMember gives userID access to threadID. A private thread is invisible until +// its members are added, so this is what makes one usable by a person. +func (t *Threads) AddMember(ctx context.Context, threadID, userID string) error { + return t.rt.Do(ctx, http.MethodPut, + "/channels/"+seg(threadID)+"/thread-members/"+seg(userID), nil, nil) +} + // CreateForum creates a forum channel named name in guildID (or the sole guild). func (t *Threads) CreateForum(ctx context.Context, guildID, name string) (*Channel, error) { gid, err := t.def.resolveGuild(ctx, guildID) diff --git a/threads_test.go b/threads_test.go index c9807dd..951a3a5 100644 --- a/threads_test.go +++ b/threads_test.go @@ -33,6 +33,45 @@ func TestThreadsStart(t *testing.T) { } } +// A private thread is created off the CHANNEL, never off a message: a message +// would be the public trace the thread exists to avoid. +func TestThreadsStartPrivate(t *testing.T) { + s := transport.NewStub().Reply(`{"id":"t9","name":"job","type":12}`) + ch, err := thr(s).StartPrivate(context.Background(), "c1", "job") + if err != nil { + t.Fatal(err) + } + if ch.ID != "t9" || ch.Type != ChannelPrivateThread { + t.Fatalf("thread = %+v", ch) + } + c := s.Last() + if c.Method != "POST" || c.Path != "/channels/c1/threads" { + t.Fatalf("call = %s %s", c.Method, c.Path) + } + body := c.Body.(map[string]any) + if body["type"] != ChannelPrivateThread { + t.Errorf("type = %v, want a private thread", body["type"]) + } + // invitable would let any member drag others in, which defeats the point. + if body["invitable"] != false { + t.Errorf("invitable = %v, want false", body["invitable"]) + } + if body["auto_archive_duration"] != autoArchive { + t.Errorf("auto_archive_duration = %v", body["auto_archive_duration"]) + } +} + +func TestThreadsAddMember(t *testing.T) { + s := transport.NewStub().Reply(``) + if err := thr(s).AddMember(context.Background(), "t9", "u1"); err != nil { + t.Fatal(err) + } + c := s.Last() + if c.Method != "PUT" || c.Path != "/channels/t9/thread-members/u1" { + t.Fatalf("call = %s %s", c.Method, c.Path) + } +} + func TestThreadsCreateForum(t *testing.T) { s := transport.NewStub(). Reply(`[{"id":"g1"}]`).