35 Commits

Author SHA1 Message Date
07e55d76e8 Only post when in production
All checks were successful
Push image to registry / build-image (push) Successful in 4m33s
2025-06-07 15:42:52 +02:00
c98c704b16 Retry posting 2025-06-07 15:42:38 +02:00
b877912d05 Fix add caption
All checks were successful
Push image to registry / build-image (push) Successful in 8m4s
2025-06-07 15:25:33 +02:00
33b9c7ce5f try with keys
All checks were successful
Push image to registry / build-image (push) Successful in 4m36s
2025-06-06 17:17:00 +02:00
e011eed4ca fix wrong js
All checks were successful
Push image to registry / build-image (push) Successful in 4m36s
2025-06-06 17:09:19 +02:00
ea2f21f8bf Remove @ from description + try fix BMP characters issue
All checks were successful
Push image to registry / build-image (push) Successful in 4m46s
2025-06-06 16:48:25 +02:00
66c4752203 Better logging in case of error while reposting reel
All checks were successful
Push image to registry / build-image (push) Successful in 4m41s
2025-06-06 11:52:49 +02:00
f92e9ff139 Fix php version required
All checks were successful
Push image to registry / build-image (push) Successful in 6m0s
2025-06-05 19:59:07 +02:00
5a6b0639e7 Should fix getting no reel to repost
Some checks failed
Push image to registry / build-image (push) Failing after 3m21s
2025-06-05 19:53:06 +02:00
ed089258c8 Does not work with php 8.4 2025-06-05 19:52:19 +02:00
70cd2a41b1 Do not throw exception if we don't have daily free
All checks were successful
Push image to registry / build-image (push) Successful in 4m42s
2025-06-05 16:35:05 +02:00
d498203147 Added debug messages 2025-06-05 16:31:23 +02:00
69fdad8f4e Do not get duplicates when finlling with non posted reels 2025-06-05 16:28:35 +02:00
982f4ca7a7 JobDebug notification are error by default 2025-06-04 16:23:56 +02:00
7e37ea86d0 better popup removal method
All checks were successful
Push image to registry / build-image (push) Successful in 4m50s
2025-06-04 16:21:07 +02:00
31c293e4a6 Reverted temporary debugs and fix failure screenshot
All checks were successful
Push image to registry / build-image (push) Successful in 6m15s
2025-06-04 15:59:55 +02:00
55a7b16d9e Fix download format
All checks were successful
Push image to registry / build-image (push) Successful in 5m27s
2025-06-04 15:45:21 +02:00
4008724169 Temporary removing renaming files
All checks were successful
Push image to registry / build-image (push) Successful in 7m47s
2025-06-04 14:59:19 +02:00
235440078d Tmporary disable removing downloaded reels
All checks were successful
Push image to registry / build-image (push) Successful in 5m25s
2025-06-04 14:41:41 +02:00
5599e170a7 Fix debug screenshot
All checks were successful
Push image to registry / build-image (push) Successful in 5m35s
2025-06-04 14:34:36 +02:00
034a2c8754 Added jobArtefacts to InstagramRepostJob
All checks were successful
Push image to registry / build-image (push) Successful in 4m45s
2025-06-03 20:21:34 +02:00
e6ff59ff18 get latest version of yt-dlp
All checks were successful
Push image to registry / build-image (push) Successful in 7m6s
2025-06-03 20:04:40 +02:00
02bd6eae82 Wait after signing in 2025-06-03 20:04:31 +02:00
e6c6ab98fd Fix php version in dockerfile
All checks were successful
Push image to registry / build-image (push) Successful in 8m1s
2025-06-03 19:35:11 +02:00
5522195438 Fix debug notification sending
Some checks failed
Push image to registry / build-image (push) Has been cancelled
2025-06-03 19:34:04 +02:00
7f50822692 Instagram repost job
Some checks failed
Push image to registry / build-image (push) Failing after 6m44s
2025-06-03 19:10:34 +02:00
25a9063169 Removed unnecessary logging 2025-06-03 19:10:04 +02:00
aa7d107c03 Removed unused tests 2025-06-03 19:09:24 +02:00
faf408582f Deactivate free giveaways slides
All checks were successful
Push image to registry / build-image (push) Successful in 7m44s
2025-04-19 15:27:46 +02:00
b49af7e56e Fix creating new battles
All checks were successful
Push image to registry / build-image (push) Successful in 4m25s
2025-03-20 21:11:57 +01:00
85aa0c7a04 Fix notification not sending
All checks were successful
Push image to registry / build-image (push) Successful in 4m14s
2025-03-20 20:35:27 +01:00
c26a09701a Allow sending helcase battle to custom discord webhook
All checks were successful
Push image to registry / build-image (push) Successful in 4m24s
2025-03-20 19:28:40 +01:00
fadb4d2748 Added job artifacts, dealing with cancelled battle & fixed and improved notification
All checks were successful
Push image to registry / build-image (push) Successful in 4m29s
2025-03-20 19:16:00 +01:00
c5f5d94912 Make better output 2025-03-20 19:14:45 +01:00
af96f996ee Fix debug notifications not being sent
All checks were successful
Push image to registry / build-image (push) Successful in 4m16s
2025-03-19 18:15:06 +01:00
37 changed files with 1309 additions and 491 deletions

View File

@ -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

View File

@ -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)
");
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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');
$browser->waitForText("Get Daily free loot", 30, true);
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) {

View File

@ -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;
}
}

View File

@ -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);
$battleLink = $browser->driver->getCurrentURL();
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;
$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([
"battle_id" => $battleLink[count($battleLink) - 1],
"value" => $battleValue,
]);
try {
HellcaseBattle::create([
"battle_id" => $battleLink[count($battleLink) - 1],
"value" => $battleValue,
]);
} catch (Exception $e) {
}
}
}
}

View File

@ -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();
}
}

View File

@ -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()}
";
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View 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;
}

View File

@ -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;
}

View File

@ -0,0 +1,458 @@
<?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
/**
* @var mixed[]
*/
$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];
$repostSuccess = false;
do {
$repostSuccess = $this->repostReel($browser, $reel, $videoInfo);
} while (!$repostSuccess && $reel->repost_tries < self::MAX_REPOST_TRIES);
$repostedReelsCounter += $repostSuccess;
}
$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
if (config("app.environment") !== "local") { // Don't share the post in local environment
$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
}
}
}
}

View 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;
}
}
}

View 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;
}
}

View 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');
}
}

View 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}/";
}
}

View File

@ -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;
}

View File

@ -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 === "") {

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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
View File

@ -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"

View File

@ -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');
}
};

View File

@ -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');

View File

@ -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');

View File

@ -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('/');
}
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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;
});
}
}

View File

@ -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');
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}