trypost/app/Socialite/DiscordProvider.php
Paulo Castellano c2dd4515b2 feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.

- Connect: custom Socialite Discord provider (bot scope) maps the authorized
  guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
  channel belongs to the connected guild (anti cross-guild), optimizes media,
  builds allowed_mentions only from explicit mention chips (no accidental pings),
  and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
  embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
  lookup endpoints, i18n (en/es/pt-BR), and tests.

Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 14:44:00 -03:00

94 lines
2.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Socialite;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
use RuntimeException;
/**
* Discord bot-authorization OAuth. The user authorizes adding our shared bot to
* one of their servers; the access-token response carries the `guild` the bot
* was added to, which becomes the connected account (id = guild id). Channel
* listing, posting and mentions all use the global bot token, not this token.
*/
class DiscordProvider extends AbstractProvider implements ProviderInterface
{
protected $scopes = ['bot', 'identify', 'guilds'];
protected $scopeSeparator = ' ';
/**
* The token-exchange response, stashed so the guild it carries can become
* the Socialite user without an extra API call.
*
* @var array<string, mixed>
*/
private array $tokenResponse = [];
protected function getAuthUrl($state): string
{
return $this->buildAuthUrlFromBase(config('trypost.platforms.discord.oauth_api').'/authorize', $state);
}
/**
* @return array<string, mixed>
*/
protected function getCodeFields($state = null): array
{
return array_merge(parent::getCodeFields($state), [
'permissions' => (string) config('trypost.platforms.discord.permissions'),
'integration_type' => 0,
]);
}
protected function getTokenUrl(): string
{
return config('trypost.platforms.discord.oauth_api').'/token';
}
/**
* @return array<string, mixed>
*/
public function getAccessTokenResponse($code): array
{
return $this->tokenResponse = parent::getAccessTokenResponse($code);
}
/**
* The bot-authorization token response includes the guild the bot joined.
*
* @return array<string, mixed>
*/
protected function getUserByToken($token): array
{
return (array) data_get($this->tokenResponse, 'guild', []);
}
/**
* @param array<string, mixed> $user The guild object.
*/
protected function mapUserToObject(array $user): User
{
$guildId = data_get($user, 'id');
// No guild means the user authorized without adding the bot to a server
// (or cancelled). Fail loudly so the callback shows an error instead of
// persisting a broken account with a null guild id.
if (blank($guildId)) {
throw new RuntimeException('Discord authorization did not include a server.');
}
$icon = data_get($user, 'icon');
return (new User)->setRaw($user)->map([
'id' => $guildId,
'nickname' => data_get($user, 'name'),
'name' => data_get($user, 'name'),
'avatar' => $guildId && $icon ? "https://cdn.discordapp.com/icons/{$guildId}/{$icon}.png" : null,
]);
}
}