Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ php8.5 tempest static:clean --force
php8.5 tempest docs:pull --no-interaction
php8.5 tempest command-palette:index
/home/forge/.bun/bin/bun run build
php8.5 tempest cache:clear --force
php8.5 tempest cache:clear --tag=default --force
php8.5 tempest view:clear --force
php8.5 tempest static:generate --verbose=true

Expand Down
40 changes: 40 additions & 0 deletions src/Advocacy/AdvocacySyncCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Advocacy;

use App\Advocacy\Discord\Discord;
use App\Advocacy\Reddit\Reddit;
use Tempest\Console\ConsoleCommand;
use Tempest\Console\HasConsole;
use Tempest\Console\Schedule;
use Tempest\Console\Scheduler\Every;

final readonly class AdvocacySyncCommand
{
use HasConsole;

public function __construct(
private Reddit $reddit,
private Discord $discord,
) {}

#[ConsoleCommand, Schedule(Every::HALF_HOUR)]
public function __invoke(bool $sync = true): void
{
$messages = $this->reddit->fetch();

// $messages = [new Message('1', 'Test', ['tempest'], 'https://tempestphp.com')];

foreach ($messages as $message) {
if ($sync) {
$this->discord->notify($message);
}

$this->info('[' . implode(',', $message->matches) . '] ' . $message->uri);
}

$this->success('Done');
}
}
27 changes: 27 additions & 0 deletions src/Advocacy/Discord/Discord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Advocacy\Discord;

use App\Advocacy\Message;
use Tempest\HttpClient\HttpClient;

final readonly class Discord
{
public function __construct(
private DiscordConfig $config,
private HttpClient $http,
) {}

public function notify(Message $message): void
{
$this->http->post(
uri: $this->config->webhookUrl,
headers: ['Content-Type' => 'application/json'],
body: json_encode([
'content' => $message->uri,
]),
);
}
}
12 changes: 12 additions & 0 deletions src/Advocacy/Discord/DiscordConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace App\Advocacy\Discord;

final readonly class DiscordConfig
{
public function __construct(
public string $webhookUrl,
) {}
}
10 changes: 10 additions & 0 deletions src/Advocacy/Discord/discord.config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

use App\Advocacy\Discord\DiscordConfig;
use function Tempest\env;

return new DiscordConfig(
webhookUrl: (string) env('DISCORD_WEBHOOK_URL', ''),
);
30 changes: 30 additions & 0 deletions src/Advocacy/Message.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Advocacy;

use function Tempest\Support\str;

final class Message
{
public string $preview {
get => str($this->body)
->substr(0, 1200)
->excerpt(
from: 0,
to: 20,
asArray: true,
)
->map(fn (string $line) => "> " . $line)
->implode(PHP_EOL)
->toString();
}

public function __construct(
public string $id,
public string $body,
public array $matches,
public string $uri,
) {}
}
63 changes: 63 additions & 0 deletions src/Advocacy/Reddit/Reddit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace App\Advocacy\Reddit;

use App\Advocacy\Message;
use Tempest\Cache\Cache;
use Tempest\Container\Tag;

final readonly class Reddit
{
public function __construct(
private RedditConfig $config,
private RedditApi $api,
private RedditMessageFactory $messageFactory,
#[Tag('advocacy')] private Cache $cache,
) {}

/** @return Message[] */
public function fetch(): array
{
$messages = [];

foreach ($this->config->filters as $subreddit => $keywords) {
$messages = [
...$messages,
...$this->fetchForSubreddit($subreddit, $keywords),
];
}

return $messages;
}

/** @return Message[] */
private function fetchForSubreddit(string $subreddit, array $keywords): array
{
$posts = $this->api->get("/r/{$subreddit}/new");
$comments = $this->api->get("/r/{$subreddit}/comments");

return $this->makeMessages([...$posts, ...$comments], $subreddit, $keywords);
}

/** @return Message[] */
private function makeMessages(array $items, string $subreddit, array $keywords): array
{
$messages = [];

foreach ($items as $item) {
$message = $this->messageFactory->fromRedditItem($item, $keywords);

if ($message === null || $this->cache->has($message->id)) {
continue;
}

$this->cache->put($message->id, true);

$messages[] = $message;
}

return $messages;
}
}
97 changes: 97 additions & 0 deletions src/Advocacy/Reddit/RedditApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace App\Advocacy\Reddit;

use RuntimeException;
use Tempest\Cache\Cache;
use Tempest\Container\Tag;
use Tempest\DateTime\Duration;
use Tempest\HttpClient\HttpClient;
use function Tempest\Support\str;

final readonly class RedditApi
{
public function __construct(
private RedditConfig $config,
private HttpClient $http,
#[Tag('advocacy')] private Cache $cache,
)
{
$this->config->token = $this->resolveToken();
}

private function resolveToken(): string
{
return $this->cache->resolve('reddit.access-token', function () {
$response = $this->http->post(
uri: 'https://www.reddit.com/api/v1/access_token?grant_type=client_credentials',
headers: [
'Authorization' => 'Basic ' . base64_encode("{$this->config->clientId}:{$this->config->clientSecret}"),
'User-Agent' => $this->config->userAgent,
],
);

$payload = $this->decodeJson($response->body);
$token = $payload['access_token'] ?? null;

if (! is_string($token) || $token === '') {
throw new RuntimeException('Failed to fetch Reddit access token.');
}

return $token;
}, Duration::minutes(50));
}

public function get(string $path): array
{
$cacheKey = str("reddit.{$path}")->slug()->toString();

return $this->cache->resolve($cacheKey, function () use ($path) {
$response = $this->http->get(
uri: "https://oauth.reddit.com{$path}?limit={$this->config->limit}",
headers: [
'Authorization' => "Bearer {$this->config->token}",
'User-Agent' => $this->config->userAgent,
],
);

$payload = $this->decodeJson($response->body);
$children = $payload['data']['children'] ?? null;

if (! is_array($children)) {
return [];
}

$items = [];

foreach ($children as $child) {
$data = $child['data'] ?? null;

if (! is_array($data)) {
continue;
}

$items[] = $data;
}

return $items;
}, Duration::days(24));
}

private function decodeJson(mixed $body): array
{
if (is_array($body)) {
return $body;
}

if (! is_string($body) || $body === '') {
return [];
}

$decoded = json_decode($body, associative: true);

return is_array($decoded) ? $decoded : [];
}
}
23 changes: 23 additions & 0 deletions src/Advocacy/Reddit/RedditConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Advocacy\Reddit;

use SensitiveParameter;
use Tempest\Http\SensitiveField;

final class RedditConfig
{
#[SensitiveField]
public ?string $token = null;

public function __construct(
/** var array<string, array<array-key string>> */
public array $filters,
#[SensitiveParameter] public string $clientId,
#[SensitiveParameter] public string $clientSecret,
public string $userAgent = 'TempestAdvocacyBot/1.0',
public int $limit = 50,
) {}
}
72 changes: 72 additions & 0 deletions src/Advocacy/Reddit/RedditMessageFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace App\Advocacy\Reddit;

use App\Advocacy\Message;

final class RedditMessageFactory
{
public function fromRedditItem(array $item, array $keywords): ?Message
{
$id = $this->resolveId($item);

if ($id === null) {
return null;
}

$matchedKeyword = $this->findMatchedKeyword($item, $keywords);

if ($matchedKeyword === null) {
return null;
}

$body = trim((string) ($item['selftext'] ?? $item['body'] ?? ''));

return new Message(
id: "advocacy.reddit.message.{$id}",
body: $body,
matches: [$matchedKeyword],
uri: $this->resolveUri($item),
);
}

private function findMatchedKeyword(array $item, array $keywords): ?string
{
$content = mb_strtolower(implode(' ', [
(string) ($item['title'] ?? ''),
(string) ($item['selftext'] ?? ''),
(string) ($item['body'] ?? ''),
(string) ($item['link_title'] ?? ''),
]));

foreach ($keywords as $keyword) {
if (mb_stripos($content, mb_strtolower($keyword)) === false) {
continue;
}

return $keyword;
}

return null;
}

private function resolveId(array $item): ?string
{
$id = $item['name'] ?? $item['id'] ?? null;

return is_string($id) && $id !== '' ? $id : null;
}

private function resolveUri(array $item): string
{
$permalink = (string) ($item['permalink'] ?? '');

if ($permalink !== '') {
return 'https://reddit.com' . $permalink;
}

return (string) ($item['url'] ?? 'https://reddit.com');
}
}
Loading
Loading