From f4b0ace68aae36aeaf7b0b97ddad42171fd74934 Mon Sep 17 00:00:00 2001 From: "Vladyslav G." Date: Wed, 2 Sep 2026 04:07:51 +0200 Subject: [PATCH] feat(channel): modify a thread, tags and all A forum post's tags could be read off Channel::$applied_tags but never written: no builder carries the field, so there was no way to move a post from "voting" to "accepted" through the library. Every forum workflow turns on exactly that. A thread is not an ordinary channel. It has no topic, no permission overwrites and no parent to move it between, and it has archiving, locking and tags, which no other channel has. Passing one to a guild channel builder sends fields Discord rejects for a thread and offers none of the ones that apply, so this is its own builder rather than another trait bolted onto the existing ones. applied_tags replaces the whole set, since that is what Discord does with the field, and is reindexed on the way out: an array with gaps in it serialises to an object and the call comes back refused. --- .../Channel/Channel/ThreadChannelBuilder.php | 86 +++++++++++++++++++ tests/Rest/ChannelTest.php | 12 +++ .../Channel/ThreadChannelBuilderTest.php | 81 +++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/Rest/Helpers/Channel/Channel/ThreadChannelBuilder.php create mode 100644 tests/Rest/Helpers/Channel/Channel/ThreadChannelBuilderTest.php diff --git a/src/Rest/Helpers/Channel/Channel/ThreadChannelBuilder.php b/src/Rest/Helpers/Channel/Channel/ThreadChannelBuilder.php new file mode 100644 index 0000000..83c304e --- /dev/null +++ b/src/Rest/Helpers/Channel/Channel/ThreadChannelBuilder.php @@ -0,0 +1,86 @@ + $tagIds + */ + public function setAppliedTags(array $tagIds): self + { + $this->data['applied_tags'] = array_values($tagIds); + + return $this; + } + + /** + * @return list|null + */ + public function getAppliedTags(): ?array + { + return $this->data['applied_tags'] ?? null; + } + + public function setArchived(bool $archived): self + { + $this->data['archived'] = $archived; + + return $this; + } + + /** + * A locked thread can still be read, but only a moderator may unarchive it. + */ + public function setLocked(bool $locked): self + { + $this->data['locked'] = $locked; + + return $this; + } + + /** + * Whether anyone in the thread may add others to it. Private threads only. + */ + public function setInvitable(bool $invitable): self + { + $this->data['invitable'] = $invitable; + + return $this; + } + + /** + * How long the thread sits idle before archiving itself, in minutes. + * Discord takes 60, 1440, 4320 or 10080. + */ + public function setAutoArchiveDuration(int $minutes): self + { + $this->data['auto_archive_duration'] = $minutes; + + return $this; + } +} diff --git a/tests/Rest/ChannelTest.php b/tests/Rest/ChannelTest.php index bf512be..f0bce73 100644 --- a/tests/Rest/ChannelTest.php +++ b/tests/Rest/ChannelTest.php @@ -22,6 +22,7 @@ use Tempcord\Discord\Rest\Helpers\Channel\Channel\GuildStageVoiceChannelBuilder; use Tempcord\Discord\Rest\Helpers\Channel\Channel\GuildTextChannelBuilder; use Tempcord\Discord\Rest\Helpers\Channel\Channel\GuildVoiceChannelBuilder; +use Tempcord\Discord\Rest\Helpers\Channel\Channel\ThreadChannelBuilder; use Tempcord\Discord\Rest\Helpers\Channel\EditMessageBuilder; use Tempcord\Discord\Rest\Helpers\Channel\EditPermissionsBuilder; use Tempcord\Discord\Rest\Helpers\Channel\MessageBuilder; @@ -192,6 +193,17 @@ public static function httpBindingsProvider(): array 'returnType' => PartsChannel::class, ] ], + 'Modify channel with Thread' => [ + 'method' => 'modify', + 'args' => ['::channel id::', new ThreadChannelBuilder()], + 'mockOptions' => [ + 'method' => 'patch', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => PartsChannel::class, + ] + ], 'Delete channel' => [ 'method' => 'delete', 'args' => ['::channel id::'], diff --git a/tests/Rest/Helpers/Channel/Channel/ThreadChannelBuilderTest.php b/tests/Rest/Helpers/Channel/Channel/ThreadChannelBuilderTest.php new file mode 100644 index 0000000..5c545b1 --- /dev/null +++ b/tests/Rest/Helpers/Channel/Channel/ThreadChannelBuilderTest.php @@ -0,0 +1,81 @@ +setAppliedTags(['::voting::', '::urgent::']); + + $this->assertSame(['applied_tags' => ['::voting::', '::urgent::']], $builder->get()); + $this->assertSame(['::voting::', '::urgent::'], $builder->getAppliedTags()); + } + + /** + * Discord replaces the whole set, so the keys have to be a plain list — an + * array with gaps in it serialises to an object and the call is refused. + */ + public function testTagsAreSentAsAList(): void + { + $tags = [3 => '::voting::', 7 => '::urgent::']; + + $this->assertSame( + '{"applied_tags":["::voting::","::urgent::"]}', + json_encode(ThreadChannelBuilder::new()->setAppliedTags($tags)->get()), + ); + } + + public function testTakingEveryTagOffIsSaidWithAnEmptyList(): void + { + $this->assertSame(['applied_tags' => []], ThreadChannelBuilder::new()->setAppliedTags([])->get()); + } + + public function testItArchivesAndLocks(): void + { + $builder = ThreadChannelBuilder::new()->setArchived(true)->setLocked(true); + + $this->assertSame(['archived' => true, 'locked' => true], $builder->get()); + } + + public function testItSetsTheIdleTimeBeforeArchiving(): void + { + $this->assertSame( + ['auto_archive_duration' => 1440], + ThreadChannelBuilder::new()->setAutoArchiveDuration(1440)->get(), + ); + } + + public function testItSetsWhoMayAddOthers(): void + { + $this->assertSame(['invitable' => false], ThreadChannelBuilder::new()->setInvitable(false)->get()); + } + + public function testItSetsSlowmode(): void + { + $this->assertSame( + ['rate_limit_per_user' => 30], + ThreadChannelBuilder::new()->setRateLimitPerUser(30)->get(), + ); + } + + public function testItRenames(): void + { + $this->assertSame(['name' => '::title::'], ThreadChannelBuilder::new()->setName('::title::')->get()); + } + + /** + * A thread has no channel type of its own to send, unlike every other + * builder here — Discord infers it from the channel being modified, and + * sending one is refused. + */ + public function testItSendsNoChannelType(): void + { + $this->assertArrayNotHasKey('type', ThreadChannelBuilder::new()->setName('::title::')->get()); + } +}