Compare commits
33 Commits
jobs/hellc
...
b877912d05
Author | SHA1 | Date | |
---|---|---|---|
b877912d05 | |||
33b9c7ce5f | |||
e011eed4ca | |||
ea2f21f8bf | |||
66c4752203 | |||
f92e9ff139 | |||
5a6b0639e7 | |||
ed089258c8 | |||
70cd2a41b1 | |||
d498203147 | |||
69fdad8f4e | |||
982f4ca7a7 | |||
7e37ea86d0 | |||
31c293e4a6 | |||
55a7b16d9e | |||
4008724169 | |||
235440078d | |||
5599e170a7 | |||
034a2c8754 | |||
e6ff59ff18 | |||
02bd6eae82 | |||
e6c6ab98fd | |||
5522195438 | |||
7f50822692 | |||
25a9063169 | |||
aa7d107c03 | |||
faf408582f | |||
b49af7e56e | |||
85aa0c7a04 | |||
c26a09701a | |||
fadb4d2748 | |||
c5f5d94912 | |||
af96f996ee |
@ -36,7 +36,7 @@ RUN mkdir -p public/build/ && npm i && npm run build
|
||||
# ========================================
|
||||
|
||||
# RUN
|
||||
FROM php:8.2-alpine AS final
|
||||
FROM php:8.3-alpine AS final
|
||||
|
||||
ARG APP_ENV_FILE=.env.docker
|
||||
|
||||
@ -59,6 +59,10 @@ RUN apk update && apk add --no-cache \
|
||||
RUN docker-php-ext-configure zip && docker-php-ext-install zip
|
||||
RUN docker-php-ext-install gd pdo pdo_mysql zip
|
||||
|
||||
# Install latest version of the linux binary of yt-dlp into /bin/yt-dlp
|
||||
# Get the file from https://github.com/yt-dlp/yt-dlp-master-builds/releases/latest/download/yt-dlp
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp-master-builds/releases/latest/download/yt-dlp -o /bin/yt-dlp \
|
||||
&& chmod +x /bin/yt-dlp
|
||||
|
||||
# Get latest Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
@ -286,4 +286,66 @@ abstract class BrowserJob implements ShouldQueue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string for use in JavaScript.
|
||||
*
|
||||
* @param string $string The string to parse.
|
||||
* @return string The parsed string.
|
||||
*/
|
||||
private function parseJavaScriptString(string $string): string
|
||||
{
|
||||
return str_replace("'", "\\'", $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text into an input field using JavaScript.
|
||||
*
|
||||
* @param Browser $browser
|
||||
* @param string $text The text to type.
|
||||
* @param string $querySelector The CSS selector for the input field.
|
||||
*/
|
||||
public function setInputValue(Browser $browser, string $text, string $querySelector): void
|
||||
{
|
||||
$text = $this->parseJavaScriptString($text);
|
||||
$querySelector = $this->parseJavaScriptString($querySelector);
|
||||
|
||||
$browser->script("
|
||||
let element = document.querySelector('{$querySelector}');
|
||||
if (element) {
|
||||
element.focus();
|
||||
element.value = '{$text}';
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
console.error('Element not found: {$querySelector}');
|
||||
}
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste text into an element using JavaScript.
|
||||
* Can be useful for non input elements that need to get text. For example works
|
||||
* with `contenteditable` elements.
|
||||
*
|
||||
* @param Browser $browser
|
||||
* @param string $text The text to paste.
|
||||
* @param string $querySelector The CSS selector for the input field.
|
||||
*/
|
||||
public function pasteText(Browser $browser, string $text, string $querySelector): void
|
||||
{
|
||||
$text = $this->parseJavaScriptString($text);
|
||||
$querySelector = $this->parseJavaScriptString($querySelector);
|
||||
|
||||
$browser->script("
|
||||
var el = document.querySelector('{$querySelector}'), text = \"{$text}\";
|
||||
el.focus();
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text', text);
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true
|
||||
});
|
||||
el.dispatchEvent(event)
|
||||
");
|
||||
}
|
||||
}
|
||||
|
@ -9,19 +9,19 @@ use function rtrim;
|
||||
class JobDebugScreenshot {
|
||||
public const IMG_FILE_NAME = "debug-";
|
||||
|
||||
public static function getFileName(int $jobId): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ".png";
|
||||
public static function getFileName(int $jobId, bool $withExtension = false): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ($withExtension ? ".png": "");
|
||||
}
|
||||
|
||||
public static function getImgFileAbsolutePath(int $jobId): string {
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId);
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId, true);
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(int $jobId): string {
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId));
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(int $jobId): string {
|
||||
return "screenshots/" . static::getFileName($jobId);
|
||||
return "screenshots/" . static::getFileName($jobId, true);
|
||||
}
|
||||
}
|
||||
|
@ -9,19 +9,19 @@ use function rtrim;
|
||||
class JobErrorScreenshot {
|
||||
public const IMG_FILE_NAME = "failure-";
|
||||
|
||||
public static function getFileName(int $jobId): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ".png";
|
||||
public static function getFileName(int $jobId, bool $withExtension = false): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ($withExtension ? ".png": "");
|
||||
}
|
||||
|
||||
public static function getImgFileAbsolutePath(int $jobId): string {
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId);
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId, true);
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(int $jobId): string {
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId));
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(int $jobId): string {
|
||||
return "screenshots/" . static::getFileName($jobId);
|
||||
return "screenshots/" . static::getFileName($jobId, true);
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,6 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
|
||||
public function __construct($jobId = 2)
|
||||
{
|
||||
Log::info("Constructing HellcaseJob");
|
||||
parent::__construct($jobId);
|
||||
}
|
||||
|
||||
@ -82,7 +81,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->makeSimpleJobRun(
|
||||
true,
|
||||
false,
|
||||
"Connexion échouée",
|
||||
"Datboi n'a pas réussi à se connecter sur Hellcase :\n" . $e->getMessage()
|
||||
);
|
||||
@ -171,7 +170,8 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
]));
|
||||
}
|
||||
try {
|
||||
$nextSlideButton = $browser->driver->findElement(WebDriverBy::xpath('//button[@class="_button_1ygbm_7 _next_1ygbm_24"]'));
|
||||
// $nextSlideButton = $browser->driver->findElement(WebDriverBy::xpath('//button[@class="_button_1ygbm_7 _next_1ygbm_24"]'));
|
||||
$nextSlideButton = "Button next slide is deactivated";
|
||||
} catch (\Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "No next slide button found"));
|
||||
@ -186,7 +186,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
} catch (\Exception $e) {
|
||||
$clickedFailsCounter++;
|
||||
try {
|
||||
$nextSlideButton->click();
|
||||
// $nextSlideButton->click();
|
||||
} catch (\Exception $_) {}
|
||||
sleep(3);
|
||||
continue;
|
||||
@ -226,7 +226,11 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
private function getDailyFree(Browser $browser)
|
||||
{
|
||||
$browser->visit('https://hellcase.com/dailyfree');
|
||||
try {
|
||||
$browser->waitForText("Get Daily free loot", 30, true);
|
||||
} catch (\Exception $e) {
|
||||
return; // Pobably means we do not have access to a daily free loot
|
||||
}
|
||||
|
||||
// Do we fill the conditions ?
|
||||
if (sizeof(value: $browser->driver->findElements(WebDriverBy::xpath('//p[contains(text(), "Fulfill the conditions below")]'))) > 0) {
|
||||
|
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use function rtrim;
|
||||
|
||||
|
||||
class HellcaseBattleScreenshot {
|
||||
public const IMG_FILE_NAME = "Hellcase-battle";
|
||||
|
||||
public static function getImgFileAbsolutePath(): string {
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/HellcaseBattles/" . static::IMG_FILE_NAME;
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(): string {
|
||||
return app_path("Browser/screenshots/HellcaseBattles/" . static::IMG_FILE_NAME);
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(): string {
|
||||
return "screenshots/HellcaseBattles/" . static::IMG_FILE_NAME;
|
||||
}
|
||||
}
|
@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
@ -20,10 +22,10 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
{
|
||||
private Collection $jobInfos;
|
||||
private array $battlesToAdd = [];
|
||||
private array $battlesSent = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Log::info("Constructing HellcaseBattlesJob");
|
||||
parent::__construct(3);
|
||||
}
|
||||
|
||||
@ -47,8 +49,16 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
|
||||
$this->sendFinishedBattles($browser);
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($this->battlesSent) . " battailles envoyées",
|
||||
]));
|
||||
|
||||
$this->createNewBattles();
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($this->battlesToAdd) . " nouvelles battailles ajoutées pour surveillage",
|
||||
]));
|
||||
|
||||
$this->jobRun->success = true;
|
||||
$this->jobRun->save();
|
||||
|
||||
@ -70,13 +80,12 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
$browser->visit('https://hellcase.com/casebattle');
|
||||
$browser->waitForText("CASES", 30, true);
|
||||
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "I hate niggers"));
|
||||
|
||||
// Sort by price
|
||||
try {
|
||||
$sortByPriceDiv = $browser->driver->findElement(WebDriverBy::xpath("//*[span[contains(text(), 'Value')]]"));
|
||||
$sortByPriceDiv->click();
|
||||
} catch (Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sort by price"));
|
||||
return;
|
||||
}
|
||||
@ -103,25 +112,28 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
$battleLinkButton = $battle->findElement(WebDriverBy::xpath('./div//button[text() = "watch"]'));
|
||||
$battleLinkButton->sendKeys("\n");
|
||||
sleep(3);
|
||||
try { // If we still are on the casebattle page, it means the battle was cancelled or something else
|
||||
$browser->waitForLocation("https://hellcase.com/casebattle/", 3);
|
||||
} catch (Exception $e) {
|
||||
$battleLink = $browser->driver->getCurrentURL();
|
||||
|
||||
$this->battlesToAdd[$battleLink] = $battleValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendFinishedBattles(Browser $browser) {
|
||||
// foreach battle that we didn"t already planned to add with $this->battlesToAdd
|
||||
foreach (HellcaseBattle::all() as $battle) {
|
||||
dump($battle);
|
||||
if (!array_key_exists($battle->getUrl(), $this->battlesToAdd)) {
|
||||
dump("finished");
|
||||
$browser->visit($battle->getUrl());
|
||||
|
||||
try {
|
||||
$browser->waitForText("Started at");
|
||||
sleep(2);
|
||||
$browser->waitForText("Case Battle");
|
||||
|
||||
if ($this->findElementContainingText($browser, "Started at:") != null) { // battle is finished
|
||||
// Send the battle
|
||||
$this->sendBattle($browser, $battle);
|
||||
} catch (Exception $e) { // Battle is not finished or error (like battle cancelled)
|
||||
}
|
||||
|
||||
$battle->delete();
|
||||
@ -130,16 +142,35 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
}
|
||||
|
||||
private function sendBattle(Browser $browser, HellcaseBattle $battle) {
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Battle sent" . $battle->getUrl()));
|
||||
try {
|
||||
$battleHeader = $browser->driver->findElement(WebDriverBy::xpath("//*[contains(@class, 'case-battle-game__header')]"));
|
||||
sleep(2); // Wait for the animations to finish
|
||||
$battleHeader->takeElementScreenshot(HellcaseBattleScreenshot::getImgFileAbsolutePath());
|
||||
} catch (Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to screenshot battle"));
|
||||
}
|
||||
$this->battlesSent[$battle->getUrl()] = $battle->value;
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($this->jobInfos->get("hellcase_battles_discord_webhook_url") !== null) { // Custom discord webhook
|
||||
$options["discord_webhook_url"] = $this->jobInfos->get("hellcase_battles_discord_webhook_url");
|
||||
}
|
||||
|
||||
AllNotification::send(new HellcaseBattlesNofication($this->jobId, $battle), $options);
|
||||
}
|
||||
|
||||
private function createNewBattles() {
|
||||
foreach ($this->battlesToAdd as $battleLink => $battleValue) {
|
||||
$battleLink = explode("/", $battleLink);
|
||||
HellcaseBattle::firstOrCreate([
|
||||
try {
|
||||
HellcaseBattle::create([
|
||||
"battle_id" => $battleLink[count($battleLink) - 1],
|
||||
"value" => $battleValue,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Notification\Notification;
|
||||
use App\Notification\Stringifiable;
|
||||
use App\Notification\Stringifiable\StringifiableSimpleText;
|
||||
|
||||
class HellcaseBattlesNofication extends Notification {
|
||||
|
||||
private HellcaseBattle $battle;
|
||||
|
||||
public function __construct(int $jobId, HellcaseBattle $battle) {
|
||||
parent::__construct($jobId);
|
||||
|
||||
$this->battle = $battle;
|
||||
|
||||
$this->setBody($this->generateBody());
|
||||
}
|
||||
|
||||
private function generateBody() {
|
||||
return new HellcaseBattlesNotificationBody($this->battle);
|
||||
}
|
||||
|
||||
public function getTitle(): Stringifiable {
|
||||
return new StringifiableSimpleText("Nouvelle bataille de caisses Hellcase");
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getImageProjectPath(): string|null {
|
||||
return HellcaseBattleScreenshot::getImgFileProjectPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLinkURL(): string|null {
|
||||
return $this->battle->getUrl();
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Notification\NotificationBody;
|
||||
|
||||
class HellcaseBattlesNotificationBody extends NotificationBody {
|
||||
|
||||
private HellcaseBattle $battle;
|
||||
|
||||
public function __construct(HellcaseBattle $battle) {
|
||||
parent::__construct();
|
||||
|
||||
$this->battle = $battle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toMarkdownString(): string {
|
||||
return "
|
||||
- Valeur : **{$this->battle->value} €**
|
||||
- Lien : **{$this->battle->getUrl()}**
|
||||
";
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toString(): string {
|
||||
return "
|
||||
- Valeur : {$this->battle->value} €
|
||||
- Lien : {$this->battle->getUrl()}
|
||||
";
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost\DescriptionPipeline;
|
||||
|
||||
/**
|
||||
* Interface for Instagram description pipeline steps.
|
||||
*/
|
||||
interface IInstagramDescriptionPipelineStep
|
||||
{
|
||||
/**
|
||||
* Process the description.
|
||||
* @param string $description
|
||||
* @return string
|
||||
*/
|
||||
public function process(string $description): string;
|
||||
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
// Pipeline for processing Instagram post descriptions
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost\DescriptionPipeline;
|
||||
|
||||
class InstagramDescriptionPipeline
|
||||
{
|
||||
/**
|
||||
* @var array of IInstagramDescriptionPipelineStep
|
||||
*/
|
||||
private array $steps;
|
||||
|
||||
/**
|
||||
* InstagramDescriptionPipeline constructor.
|
||||
*
|
||||
* @param array $steps
|
||||
*/
|
||||
public function __construct(array $steps)
|
||||
{
|
||||
$this->steps = $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the description through the pipeline.
|
||||
*
|
||||
* @param string $description
|
||||
* @return string
|
||||
*/
|
||||
public function process(string $description): string
|
||||
{
|
||||
foreach ($this->steps as $step) {
|
||||
if (!$step instanceof IInstagramDescriptionPipelineStep) {
|
||||
throw new \InvalidArgumentException('All steps must implement IInstagramDescriptionPipelineStep interface.');
|
||||
}
|
||||
|
||||
$description = $step->process($description);
|
||||
}
|
||||
|
||||
return $description;
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost\DescriptionPipeline;
|
||||
|
||||
/**
|
||||
* Step to remove account references from the description.
|
||||
*/
|
||||
|
||||
class RemoveAccountsReferenceStep implements IInstagramDescriptionPipelineStep
|
||||
{
|
||||
/**
|
||||
* Process the description to remove account references.
|
||||
*
|
||||
* @param string $description
|
||||
* @return string
|
||||
*/
|
||||
public function process(string $description): string
|
||||
{
|
||||
// Regular expression to match Instagram account references
|
||||
$pattern = '/@([a-zA-Z0-9._]+)/';
|
||||
|
||||
// Split by lines
|
||||
$lines = explode("\n", $description);
|
||||
|
||||
// Remove lines that contain account references
|
||||
$lines = array_filter($lines, function ($line) use ($pattern) {
|
||||
// Check if the line does not match the account reference pattern
|
||||
return !preg_match($pattern, $line);
|
||||
});
|
||||
|
||||
// Join the remaining lines back into a single string
|
||||
$cleanedDescription = implode("\n", $lines);
|
||||
|
||||
// Trim whitespace from the beginning and end of the description
|
||||
return trim($cleanedDescription);
|
||||
}
|
||||
}
|
54
app/Browser/Jobs/InstagramRepost/IInstagramVideo.php
Normal file
54
app/Browser/Jobs/InstagramRepost/IInstagramVideo.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Interface for Instagram video handling.
|
||||
* Holds the data of a video from Instagram.
|
||||
*/
|
||||
interface IInstagramVideo
|
||||
{
|
||||
/**
|
||||
* Get the URL of the video.
|
||||
*
|
||||
* @return string The URL of the video.
|
||||
*/
|
||||
public function getUrl(): string;
|
||||
|
||||
/**
|
||||
* Get the title of the video.
|
||||
*
|
||||
* @return string The title of the video.
|
||||
*/
|
||||
public function getTitle(): string;
|
||||
|
||||
/**
|
||||
* Get the caption of the video.
|
||||
*
|
||||
* @return string The caption of the video.
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
|
||||
/**
|
||||
* Get the date when the video was posted.
|
||||
*
|
||||
* @return ?DateTimeImmutable The date of the post.
|
||||
*/
|
||||
public function getPostDate(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Get the filename of the video.
|
||||
*
|
||||
* @return string The filename of the video.
|
||||
*/
|
||||
public function getFilename(): string;
|
||||
|
||||
/**
|
||||
* Set the filename of the video.
|
||||
*
|
||||
* @param string $filename The filename to set.
|
||||
*/
|
||||
public function setFilename(string $filename): void;
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Models\JobRun;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface IInstagramVideoDownloader
|
||||
{
|
||||
/**
|
||||
* Download the video from the given Instagram post URL.
|
||||
*
|
||||
* @param int $jobId The ID of the job for which the video is being downloaded.
|
||||
* @param JobRun $jobRun The job run instance for logging and tracking.
|
||||
* @param string $postUrl The URL of the Instagram post.
|
||||
* @param Collection $jobInfos The job information collection.
|
||||
* @param string $downloadFolder The folder where the video should be downloaded.
|
||||
* @param string $accountEmail The email or username of the Instagram account.
|
||||
* @param string $accountPassword The password for the Instagram account.
|
||||
* @return IInstagramVideo|null The path to the downloaded video file, or null if the download failed.
|
||||
*/
|
||||
public function downloadVideo(int $jobId, JobRun $jobRun, string $postUrl, string $downloadFolder, string $accountEmail, string $accountPassword): ?IInstagramVideo;
|
||||
}
|
450
app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php
Normal file
450
app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php
Normal file
@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Browser\BrowserJob;
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
|
||||
use App\Models\InstagramRepost;
|
||||
use App\Models\InstagramAccount;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// === CONFIGURATION ===
|
||||
// TODO : put that in laravel config file
|
||||
/**
|
||||
* Maximum number of posts to repost per account
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_REPOSTS_PER_ACCOUNT = 2;
|
||||
|
||||
/**
|
||||
* Max number of reposts per job
|
||||
* This is the maximum number of posts that will be reposted in a single job run.
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_REPOSTS_PER_JOB = 3;
|
||||
|
||||
/**
|
||||
* Maximum number of tries to repost a reel
|
||||
* If a reel fails to be reposted, it will be retried up to this number of times.
|
||||
* @var int
|
||||
*/
|
||||
private const MAX_REPOST_TRIES = 3;
|
||||
|
||||
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
|
||||
|
||||
private Collection $jobInfos;
|
||||
protected JobRun $jobRun;
|
||||
|
||||
protected IInstagramVideoDownloader $videoDownloader;
|
||||
|
||||
protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/";
|
||||
|
||||
/**
|
||||
* Pipeline for processing Instagram post descriptions.
|
||||
* This pipeline can be used to modify the description before reposting.
|
||||
* For example, it can remove account references or add hashtags.
|
||||
*
|
||||
* @var InstagramDescriptionPipeline
|
||||
*/
|
||||
protected InstagramDescriptionPipeline $descriptionPipeline;
|
||||
|
||||
public function __construct($jobId = 4)
|
||||
{
|
||||
parent::__construct($jobId);
|
||||
|
||||
$this->downloadFolder = base_path($this->downloadFolder);
|
||||
$this->videoDownloader = new YTDLPDownloader();
|
||||
$this->descriptionPipeline = new InstagramDescriptionPipeline([
|
||||
// Add steps to the pipeline here
|
||||
new DescriptionPipeline\RemoveAccountsReferenceStep(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
{
|
||||
Log::info("Running InstagramRepostJob");
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
$this->jobRun = new JobRun([
|
||||
"job_id" => $this->jobId,
|
||||
"success" => false,
|
||||
]);
|
||||
$this->jobRun->save();
|
||||
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(5);
|
||||
$this->removePopups($browser);
|
||||
sleep(2);
|
||||
$this->signin($browser);
|
||||
sleep(2);
|
||||
$this->repostLatestPosts($browser);
|
||||
sleep(5);
|
||||
|
||||
$this->jobRun->success = true;
|
||||
$this->jobRun->save();
|
||||
|
||||
Log::info("InstagramRepostJob run ended");
|
||||
|
||||
return $this->jobRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function runTest(Browser $browser): ?JobRun
|
||||
{
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
try {
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(2);
|
||||
$this->removePopups($browser);
|
||||
sleep(2);
|
||||
$this->signin($browser);
|
||||
sleep(3);
|
||||
return $this->makeSimpleJobRun(
|
||||
true,
|
||||
"Connexion réussie",
|
||||
"Datboi a réussi à se connecter sur Instagram"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->makeSimpleJobRun(
|
||||
false,
|
||||
"Connexion échouée",
|
||||
"Datboi n'a pas réussi à se connecter sur Instagram :\n" . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function repostLatestPosts(Browser $browser) {
|
||||
|
||||
try {
|
||||
// Download the latest reels from the accounts specified in the job infos
|
||||
$toDownloadReels = []; // Array to store to download reels to post later
|
||||
|
||||
$accounts = explode(",", $this->jobInfos->get("instagram_repost_accounts"));
|
||||
foreach ($accounts as &$account) {
|
||||
$account = trim($account);
|
||||
|
||||
$toDownloadReels = array_merge($toDownloadReels, $this->getLatestReelsFromAccount($browser, $account));
|
||||
}
|
||||
|
||||
// Add unreposted reels to the job run if not enough reels were downloaded
|
||||
if (count($toDownloadReels) < self::MAX_REPOSTS_PER_JOB) {
|
||||
$toDownloadReelsIds = array_map(function ($reel) {
|
||||
return $reel->reel_id;
|
||||
}, $toDownloadReels);
|
||||
|
||||
$unrepostedReels = InstagramRepost::where("reposted", false)
|
||||
->where("repost_tries", "<", self::MAX_REPOST_TRIES) // Limit to 3 tries
|
||||
->whereNotIn("reel_id", $toDownloadReelsIds) // Avoid already downloaded reels
|
||||
->whereIn("account_id", InstagramAccount::whereIn("username", $accounts)->pluck("id"))
|
||||
->take(self::MAX_REPOSTS_PER_JOB - count($toDownloadReels))
|
||||
->get();
|
||||
|
||||
foreach ($unrepostedReels as $reel) {
|
||||
$toDownloadReels[] = $reel;
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffling and keeping only the x first posts
|
||||
shuffle($toDownloadReels);
|
||||
$toDownloadReels = array_slice($toDownloadReels, 0, self::MAX_REPOSTS_PER_JOB);
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($toDownloadReels) . " reels sélectionnés pour être repost",
|
||||
"content" => ""
|
||||
]));
|
||||
|
||||
// Download the reels
|
||||
$downloadedReels = [];
|
||||
foreach ($toDownloadReels as $repost) {
|
||||
$downloadedReels[] = [
|
||||
$repost,
|
||||
$this->downloadReel(
|
||||
$browser,
|
||||
$repost
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($downloadedReels) . " reels sélectionnés qui ont été téléchargés",
|
||||
"content" => ""
|
||||
]));
|
||||
|
||||
// Now repost all downloaded reels
|
||||
$repostedReelsCounter = 0;
|
||||
foreach ($downloadedReels as $infos) {
|
||||
$reel = $infos[0];
|
||||
$videoInfo = $infos[1];
|
||||
|
||||
// TODO : Avoid getting the reel from the db again, store it in the downloadedReels array
|
||||
$repostedReelsCounter += $this->repostReel($browser, InstagramRepost::where('reel_id', $reel->reel_id)->first(), $videoInfo);
|
||||
}
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => $repostedReelsCounter . " reels sélectionnés qui ont été repostés",
|
||||
"content" => ""
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Unknown error when trying to repost reels : " . $e->getMessage()));
|
||||
} finally {
|
||||
// Removes all videos in the download folder
|
||||
$files = glob($this->downloadFolder . '*'); // Get all files in the download folder
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
unlink($file); // Delete the file
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getLatestReelsFromAccount(Browser $browser, string $account): array
|
||||
{
|
||||
$accountReels = []; // Indexed array to store new reels from the account
|
||||
|
||||
$browser->visit("https://instagram.com/{$account}/reels");
|
||||
sleep(3);
|
||||
$browser->waitForText("followers", 10, true);
|
||||
|
||||
// If we are here, the account exists
|
||||
$accountModel = InstagramAccount::where("username", $account)->first();
|
||||
if ($accountModel == null) { // Does not exist in the database yet
|
||||
$accountModel = new InstagramAccount([
|
||||
"username" => $account,
|
||||
]);
|
||||
$accountModel->save();
|
||||
Log::info("New Instagram account added: {$account}");
|
||||
} else {
|
||||
Log::debug("Instagram account already exists: {$account}");
|
||||
}
|
||||
|
||||
$repostedPosts = $accountModel->reposts()->where("reposted", true)->pluck("reel_id");
|
||||
|
||||
// Posts must be sorted by latest post date first
|
||||
// TODO : Scroll when not enough post are shown
|
||||
$posts = $browser->driver->findElements(WebDriverBy::xpath('//a[contains(@href, "'.$account.'/reel/")][not(.//*[local-name() = "svg"][@aria-label="Pinned post icon"])]'));
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$postUrl = $post->getAttribute('href');
|
||||
$postId = explode("/", $postUrl)[3] ?? null;
|
||||
|
||||
if ($postId === null) {
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Can't get Instagram post ID from url"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break the loop if the post has already been reposted
|
||||
if ($repostedPosts->contains($postId)) {
|
||||
Log::debug("Post already reposted: {$postUrl}");
|
||||
break;
|
||||
}
|
||||
|
||||
$reelModel = InstagramRepost::firstOrCreate(
|
||||
["reel_id" => $postId, "account_id" => $accountModel->id],
|
||||
["reposted" => false, "repost_tries" => 0]
|
||||
);
|
||||
|
||||
if (count($accountReels) < self::MAX_REPOSTS_PER_ACCOUNT) {
|
||||
$accountReels[] = $reelModel; // Add it to the to be downloaded reels array
|
||||
}
|
||||
}
|
||||
|
||||
return $accountReels;
|
||||
}
|
||||
|
||||
private function downloadReel(Browser $browser, InstagramRepost $reel): ?IInstagramVideo
|
||||
{
|
||||
$videoInfo = $this->videoDownloader->downloadVideo(
|
||||
$this->jobId,
|
||||
$this->jobRun,
|
||||
$reel->getUrl(),
|
||||
$this->downloadFolder,
|
||||
$this->jobInfos->get("instagram_repost_account_email"),
|
||||
$this->jobInfos->get("instagram_repost_account_password")
|
||||
);
|
||||
|
||||
if ($videoInfo === null) {
|
||||
Log::error("Failed to download video for post: {$reel->reel_id}");
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
// Set the filename to the post ID
|
||||
$newFilename = $this->downloadFolder . $reel->reel_id . ".mp4";
|
||||
rename($videoInfo->getFilename(), $newFilename);
|
||||
$videoInfo->setFilename($newFilename);
|
||||
}
|
||||
|
||||
Log::info("Downloaded video: {$videoInfo->getTitle()} : {$videoInfo->getDescription()}");
|
||||
|
||||
return $videoInfo;
|
||||
}
|
||||
|
||||
protected function repostReel(Browser $browser, InstagramRepost $reel, IInstagramVideo $videoInfo): bool
|
||||
{
|
||||
try {
|
||||
Log::info("Reposting reel: {$reel->reel_id} - {$videoInfo->getTitle()}");
|
||||
|
||||
// Increment the repost tries
|
||||
$reel->repost_tries++;
|
||||
$reel->save();
|
||||
|
||||
// TODO Reset if a problem occurs and try again with a limit of 3 attempts
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(2);
|
||||
|
||||
// Navigate to the reel upload page
|
||||
$createButton = $browser->driver->findElement(WebDriverBy::xpath('//a[./div//span[contains(text(), "Create")]]'));
|
||||
$createButton->click();
|
||||
sleep(2);
|
||||
$newPostButton = $browser->driver->findElement(WebDriverBy::xpath('//a[./div//span[contains(text(), "Post")]][@href="#"]'));
|
||||
$newPostButton->click();
|
||||
sleep(3);
|
||||
|
||||
// Upload the video file
|
||||
$selectFileButton = $browser->driver->findElement(WebDriverBy::xpath('//button[contains(text(), "Select from computer")]'));
|
||||
$selectFileButton->click();
|
||||
sleep(2);
|
||||
$browser->attach('input[type="file"]._ac69', $this->downloadFolder . $reel->reel_id . ".mp4");
|
||||
|
||||
sleep(10); // TODO : Wait for the file to be uploaded
|
||||
|
||||
$this->removePopups($browser);
|
||||
sleep(2);
|
||||
|
||||
// Put original resolution
|
||||
$this->putOriginalResolution($browser);
|
||||
|
||||
$this->clickNext($browser);
|
||||
$this->clickNext($browser); // Skip cover photo and trim
|
||||
|
||||
// Add a caption
|
||||
$captionText = $this->descriptionPipeline->process($videoInfo->getDescription());
|
||||
$this->pasteText($browser, $captionText, 'div[contenteditable]');
|
||||
|
||||
sleep(2); // Wait for the caption to be added
|
||||
|
||||
$this->clickNext($browser); // Share the post
|
||||
|
||||
sleep(5); // Wait for the post to be completed
|
||||
|
||||
// Check if the post was successful
|
||||
try {
|
||||
$browser->waitForText("Your reel has been shared.", 60, true);
|
||||
Log::info("Reel reposted successfully: {$reel->reel_id}");
|
||||
|
||||
// Mark the reel as reposted in the database
|
||||
$reel->reposted = true;
|
||||
$reel->save();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
try {
|
||||
$browser->waitForText("Your post was shared", 60, true);
|
||||
$closeButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/*[local-name() = "svg"][@aria-label="Close"]]'));
|
||||
$closeButton->click();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to repost reel: {$reel->reel_id} - " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to repost reel: {$reel->reel_id} - " . $e->getMessage()));
|
||||
return false;
|
||||
}
|
||||
return true; // Reel reposted successfully, no error message found
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to repost reel: {$reel->reel_id} - " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to repost reel: {$reel->reel_id} - " . $e->getMessage()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function clickNext(Browser $browser) {
|
||||
$nextButton = $browser->driver->findElement(WebDriverBy::xpath('//div[contains(text(), "Next") or contains(text(), "Share")]'));
|
||||
$nextButton->click();
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
private function putOriginalResolution(Browser $browser)
|
||||
{
|
||||
try {
|
||||
$chooseResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//button[./div/*[local-name() = "svg"][@aria-label="Select crop"]]'));
|
||||
$chooseResolutionButton->click();
|
||||
sleep(2);
|
||||
// Choos "original" resolution
|
||||
$originalResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/div/span[contains(text(), "Original")]]'));
|
||||
$originalResolutionButton->click();
|
||||
sleep(2);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to set original resolution: " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to set \"original\" resolution: " . $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
protected function signin(Browser $browser)
|
||||
{
|
||||
try {
|
||||
$browser->waitForText("Log in", 10, true);
|
||||
sleep(3);
|
||||
$emailButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "email")]'));
|
||||
$emailButton->click();
|
||||
$emailButton->sendKeys($this->jobInfos->get("instagram_repost_account_email"));
|
||||
sleep(3);
|
||||
$passwordButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "Password")]'));
|
||||
$passwordButton->click();
|
||||
$passwordButton->sendKeys($this->jobInfos->get("instagram_repost_account_password") . "\n");
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
// Probably no need to signin
|
||||
}
|
||||
try {
|
||||
$browser->waitForText("Search", 15, true);
|
||||
$this->removePopups($browser);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to sign in: " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sign in: " . $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
protected function removePopups(Browser $browser)
|
||||
{
|
||||
$popupsTypes = [
|
||||
['//button[contains(text(), "Allow all cookies")]'], // Allow all cookies
|
||||
['//button[contains(text(), "Not Now")]', ["Popup Not Now clicked"]], // Not now
|
||||
['//button[contains(text(), "OK")]', ["Popup Ok clicked"]], // OK
|
||||
];
|
||||
|
||||
foreach ($popupsTypes as $popup) {
|
||||
try {
|
||||
$button = $browser->driver->findElement(WebDriverBy::xpath($popup[0]));
|
||||
if ($button === null) {
|
||||
continue; // No button found, continue to the next popup
|
||||
}
|
||||
if (isset($popup[1])) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, $popup[1][0]));
|
||||
}
|
||||
$button->click();
|
||||
sleep(2);
|
||||
return; // Exit after clicking the first popup found
|
||||
} catch (\Exception $e) {
|
||||
// Porbably no popup found, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
56
app/Browser/Jobs/InstagramRepost/YTDLPDownloader.php
Normal file
56
app/Browser/Jobs/InstagramRepost/YTDLPDownloader.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use YoutubeDl\Options;
|
||||
use YoutubeDl\YoutubeDl;
|
||||
|
||||
class YTDLPDownloader implements IInstagramVideoDownloader
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function downloadVideo(int $jobId, JobRun $jobRun, string $postUrl, string $downloadFolder, string $accountEmail, string $accountPassword): ?YTDLPVideo
|
||||
{
|
||||
$dl = new YoutubeDl();
|
||||
$options = Options::create()
|
||||
->downloadPath($downloadFolder)
|
||||
->apLogin($accountEmail, $accountPassword)
|
||||
->checkAllFormats(true)
|
||||
//->format('bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]') // TODO allow downloading audio and video separately and then merging them
|
||||
->format('best[ext=mp4]')
|
||||
->url($postUrl);
|
||||
|
||||
try {
|
||||
$videosCollection = $dl->download($options);
|
||||
foreach ($videosCollection->getVideos() as $video) {
|
||||
if ($video->getError() !== null) {
|
||||
$jobRun->addArtifact(new JobArtifact([
|
||||
"name" => "Erreur lors du téléchargement de la vidéo \"{$video->getTitle()}\"",
|
||||
"content" => $video->getError(),
|
||||
]));
|
||||
Log::error("Error downloading video: " . $video->getError());
|
||||
return null; // Return null if there was an error downloading the video
|
||||
} else {
|
||||
$IVideo = new YTDLPVideo(
|
||||
$video->getWebpageUrl(),
|
||||
$video->getTitle(),
|
||||
$video->getDescription() ?? "",
|
||||
$video->getUploadDate(),
|
||||
$video->getFilename(),
|
||||
);
|
||||
|
||||
return $IVideo; // Return the video object if download was successful
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
AllNotification::send(new JobDebugNotification($jobId, "Error while downloading video: " . $e->getMessage()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
71
app/Browser/Jobs/InstagramRepost/YTDLPVideo.php
Normal file
71
app/Browser/Jobs/InstagramRepost/YTDLPVideo.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
class YTDLPVideo implements IInstagramVideo
|
||||
{
|
||||
private string $url;
|
||||
private string $title;
|
||||
private string $description;
|
||||
private ?DateTimeImmutable $postDate;
|
||||
private string $fileName;
|
||||
|
||||
public function __construct(string $url, string $title, string $description, ?DateTimeImmutable $postDate, string $filename)
|
||||
{
|
||||
$this->url = $url;
|
||||
$this->title = $title;
|
||||
$this->description = $description;
|
||||
$this->postDate = $postDate;
|
||||
$this->fileName = $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPostDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->postDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setFilename(string $filename): void
|
||||
{
|
||||
$this->fileName = $filename;
|
||||
}
|
||||
}
|
19
app/Models/InstagramAccount.php
Normal file
19
app/Models/InstagramAccount.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstagramAccount extends Model
|
||||
{
|
||||
protected $table = 'instagram_repost_accounts';
|
||||
|
||||
protected $fillable = [
|
||||
'username',
|
||||
];
|
||||
|
||||
public function reposts()
|
||||
{
|
||||
return $this->hasMany(InstagramRepost::class, 'account_id');
|
||||
}
|
||||
}
|
26
app/Models/InstagramRepost.php
Normal file
26
app/Models/InstagramRepost.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstagramRepost extends Model
|
||||
{
|
||||
protected $table = 'instagram_reposts';
|
||||
|
||||
protected $fillable = [
|
||||
'reel_id',
|
||||
'reposted',
|
||||
'account_id',
|
||||
];
|
||||
|
||||
public function account()
|
||||
{
|
||||
return $this->belongsTo(InstagramAccount::class, 'account_id');
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return "https://www.instagram.com/reel/{$this->reel_id}/";
|
||||
}
|
||||
}
|
@ -3,5 +3,5 @@
|
||||
namespace App\Notification;
|
||||
|
||||
abstract class NotificationProvider {
|
||||
abstract public static function send(Notification $notification): void;
|
||||
abstract public static function send(Notification $notification, array $options): void;
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ class JobDebugNotification extends Notification {
|
||||
private string|null $title;
|
||||
private string|null $screenShotProjectPath;
|
||||
|
||||
public function __construct(int $jobId, string $body, string $title = null, string $error = null, ?string $screenshotProjectPath = "", bool $isError = false) {
|
||||
public function __construct(int $jobId, string $body, string $title = null, string $error = null, ?string $screenshotProjectPath = "", bool $isError = true) {
|
||||
parent::__construct($jobId, isError:$isError);
|
||||
$this->title = $title;
|
||||
if ($screenshotProjectPath === "") {
|
||||
|
@ -12,9 +12,9 @@ class AllNotification extends NotificationProvider {
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public static function send(\App\Notification\Notification $notification): void {
|
||||
public static function send(\App\Notification\Notification $notification, array $options = []): void {
|
||||
foreach (self::NOTIFICATIONS_PROVIDERS as $provider) {
|
||||
$provider::send($notification);
|
||||
$provider::send($notification, $options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ class DiscordWebHookNotification extends NotificationProvider {
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public static function send(\App\Notification\Notification $notification): void {
|
||||
public static function send(\App\Notification\Notification $notification, array $options): void {
|
||||
/*
|
||||
Test Json for a complete embed :
|
||||
{
|
||||
@ -44,7 +44,7 @@ class DiscordWebHookNotification extends NotificationProvider {
|
||||
"avatar_url": "https://www.fairytailrp.com/t40344-here-come-dat-boi"
|
||||
}
|
||||
*/
|
||||
$webHookUrl = static::getDiscordWebHookUrl($notification->isError);
|
||||
$webHookUrl = static::getDiscordWebHookUrl($notification->isError, $options);
|
||||
$body = [
|
||||
"content"=> "",
|
||||
"tts"=> false,
|
||||
@ -73,20 +73,28 @@ class DiscordWebHookNotification extends NotificationProvider {
|
||||
'payload_json' => $payloadJson,
|
||||
];
|
||||
|
||||
if ($notification->getImageURL() !== null) {
|
||||
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
|
||||
if ($notification->getImageURL() !== null && is_file($notification->getImageProjectPath())) {
|
||||
}
|
||||
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
|
||||
|
||||
$ch = curl_init($webHookUrl);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-type: multipart/form-data'));
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $formData);
|
||||
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
$response = curl_exec($ch);
|
||||
if (!curl_exec($ch)) {
|
||||
$error = curl_error($ch);
|
||||
\Log::error("Discord WebHook Notification failed: {$error}");
|
||||
throw new \Exception("Discord WebHook Notification failed: {$error}");
|
||||
}
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
private static function getDiscordWebHookUrl(bool $isError): string {
|
||||
private static function getDiscordWebHookUrl(bool $isError, array $options): string {
|
||||
if (isset($options["discord_webhook_url"]) && $options["discord_webhook_url"] !== null) {
|
||||
return $options["discord_webhook_url"];
|
||||
}
|
||||
|
||||
$generalWebHookUrlKey = 'discord_webhook_url';
|
||||
$generalWebHookUrl = Cache::rememberForever($generalWebHookUrlKey, function () use ($generalWebHookUrlKey) {
|
||||
return JobInfo::where('key', $generalWebHookUrlKey)->first()->value;
|
||||
|
@ -9,7 +9,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "8.3.*",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/dusk": "^8.2",
|
||||
@ -18,6 +18,7 @@
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/telescope": "^5.5",
|
||||
"laravel/tinker": "^2.9",
|
||||
"norkunas/youtube-dl-php": "dev-master",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
137
composer.lock
generated
137
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6008577001548e6e63c074be98000d97",
|
||||
"content-hash": "9a964008040d9ce219547515fe65dd86",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@ -3011,6 +3011,69 @@
|
||||
},
|
||||
"time": "2024-12-30T11:07:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "norkunas/youtube-dl-php",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/norkunas/youtube-dl-php.git",
|
||||
"reference": "4c954b3b8c6b30d0c0135ec758b61a91f0ac3b6a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/norkunas/youtube-dl-php/zipball/4c954b3b8c6b30d0c0135ec758b61a91f0ac3b6a",
|
||||
"reference": "4c954b3b8c6b30d0c0135ec758b61a91f0ac3b6a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": ">=7.4.0",
|
||||
"symfony/filesystem": "^5.1|^6.0|^7.0",
|
||||
"symfony/polyfill-php80": "^1.28",
|
||||
"symfony/process": "^5.1|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mikey179/vfsstream": "^1.6.11",
|
||||
"php-cs-fixer/shim": "^3.60",
|
||||
"phpstan/phpstan": "^1.11.8",
|
||||
"phpstan/phpstan-phpunit": "^1.4.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.6.0",
|
||||
"symfony/phpunit-bridge": "^6.4.10"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"YoutubeDl\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tomas Norkūnas",
|
||||
"email": "norkunas.tom@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "youtube-dl / yt-dlp wrapper for php",
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"yt-dlp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/norkunas/youtube-dl-php/issues",
|
||||
"source": "https://github.com/norkunas/youtube-dl-php/tree/v2.10.0"
|
||||
},
|
||||
"time": "2025-02-20T17:32:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nunomaduro/termwind",
|
||||
"version": "v2.3.0",
|
||||
@ -5211,6 +5274,72 @@
|
||||
],
|
||||
"time": "2024-09-25T14:20:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb",
|
||||
"reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^6.4|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-25T15:15:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.2.2",
|
||||
@ -9591,11 +9720,13 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {
|
||||
"norkunas/youtube-dl-php": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
"php": "^8.3"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstagramAccount;
|
||||
use App\Models\InstagramRepost;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobInfo;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$newJobId = 4;
|
||||
Job::forceCreate([
|
||||
"id" => $newJobId,
|
||||
"name" => "Instagram Repost",
|
||||
"description" => "Reposte les publications Instagram des comptes donnés.",
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "instagram_repost_accounts",
|
||||
"name" => "Comptes Instagram à reposter",
|
||||
"description" => "Liste des noms des comptes Instagram à partir desquels les publications seront repostées.\nSéparez les comptes par des virgules.",
|
||||
"placeholder" => "is.it.ninluc, freddiedredd",
|
||||
"is_required" => true,
|
||||
"job_info_type_id" => 1,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "instagram_repost_account_email",
|
||||
"name" => "Identifiant",
|
||||
"description" => "L'adresse e-mail/nom d'utilisateur/N° de téléphone utilisée pour le compte Instagram de repost.",
|
||||
"is_required" => true,
|
||||
"job_info_type_id" => 1,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "instagram_repost_account_password",
|
||||
"name" => "Mot de passe",
|
||||
"description" => "Le mot de passe utilisée pour le compte Instagram de repost.",
|
||||
"is_required" => true,
|
||||
"job_info_type_id" => 3,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
Schema::create('instagram_repost_accounts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string("username")->unique();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('instagram_reposts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string("reel_id")->unique();
|
||||
$table->boolean("reposted")->default(false);
|
||||
$table->integer("repost_tries")->default(0);
|
||||
$table->foreignIdFor(InstagramAccount::class, "account_id")
|
||||
->constrained('instagram_repost_accounts')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Already reposted posts
|
||||
$notDeadLmaoAccount = InstagramAccount::forceCreate([
|
||||
"username" => "notdeadlmao69",
|
||||
]);
|
||||
$negusflexAccount = InstagramAccount::forceCreate([
|
||||
"username" => "negusflex",
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DKbW7M_RWV7",
|
||||
"reposted" => true,
|
||||
"account_id" => $notDeadLmaoAccount->id,
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DKccuTMTmP_",
|
||||
"reposted" => true,
|
||||
"account_id" => $negusflexAccount->id,
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DJmUjhWSnqm",
|
||||
"reposted" => true,
|
||||
"account_id" => $negusflexAccount->id,
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DKcdSGnv6uq",
|
||||
"reposted" => true,
|
||||
"account_id" => $negusflexAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Job::where("id", 4)->delete();
|
||||
JobInfo::where("job_id", 4)->delete();
|
||||
|
||||
Schema::dropIfExists('instagram_repost_accounts');
|
||||
Schema::dropIfExists('instagram_reposts');
|
||||
}
|
||||
};
|
@ -13,7 +13,7 @@ Route::get('/jobs', function (Request $request) {
|
||||
Route::get('/test/{id}', function (Request $request, $id, BrowserJobsInstances $BrowserJobsInstances) {
|
||||
$log = $BrowserJobsInstances->getJobInstance($id)->execute();
|
||||
dump($log);
|
||||
return response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]);
|
||||
dump(response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]));
|
||||
});
|
||||
|
||||
Route::get('jobs/{job}/test', [JobController::class, 'test'])->name('jobs.test');
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob;
|
||||
use App\Browser\Jobs\InstagramRepost\InstagramRepostJob;
|
||||
use App\Jobs\PruneOldJobRuns;
|
||||
use App\Services\BrowserJobsInstances;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
@ -23,3 +24,4 @@ Schedule::job(new PruneOldJobRuns)->monthly()->onOneServer()->withoutOverlapping
|
||||
Schedule::job(new HellcaseJob)->daily()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
|
||||
// Schedule::job(new HellcaseJob)->everyMinute()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
|
||||
Schedule::job(new HellcaseBattlesJob)->hourly()->onOneServer()->withoutOverlapping()->name('hellcase_battles')->description('Hellcase battles job');
|
||||
Schedule::job(new InstagramRepostJob())->everyThreeHours()->onOneServer()->withoutOverlapping()->name('instagram_reposts')->description('Intagrame reposts job');
|
||||
|
@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_password_is_not_confirmed_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_password_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_update_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrors('current_password')
|
||||
->assertRedirect('/profile');
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_profile_page_is_displayed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/profile');
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/profile', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrors('password')
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user