From 37ff8e0a88282ebf244a4b8af3646898da7d5308 Mon Sep 17 00:00:00 2001
From: josh
Date: Fri, 28 Aug 2026 23:53:45 +1000
Subject: [PATCH] Initial Commit
---
app/Console/Commands/DebugPlaywright.php | 2 +-
app/Console/Commands/ScrapeDirectMessages.php | 1 +
app/Http/Controllers/StoryController.php | 29 +++++
app/Http/Controllers/ThreadController.php | 45 ++++++-
app/Models/DirectMessage.php | 3 +-
playwright/debugger.spec.js | 22 ++--
playwright/scrape-messages.spec.js | 13 ++
.../dredge/Messages/MessageBubble.vue | 15 ++-
.../dredge/Messages/MessageMedia.vue | 54 +++++++++
.../dredge/Messages/MessageStoryShare.vue | 112 ++++++++++++++++++
.../components/dredge/Stories/StoryViewer.vue | 10 +-
resources/js/types/dredge.types.ts | 8 ++
routes/api.php | 1 +
13 files changed, 292 insertions(+), 23 deletions(-)
create mode 100644 resources/js/components/dredge/Messages/MessageMedia.vue
create mode 100644 resources/js/components/dredge/Messages/MessageStoryShare.vue
diff --git a/app/Console/Commands/DebugPlaywright.php b/app/Console/Commands/DebugPlaywright.php
index 393b240..d017015 100644
--- a/app/Console/Commands/DebugPlaywright.php
+++ b/app/Console/Commands/DebugPlaywright.php
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
-#[Signature('app:debug-playwright')]
+#[Signature('direct:debug-playwright')]
#[Description('Command description')]
class DebugPlaywright extends RunsPlaywrightScript
{
diff --git a/app/Console/Commands/ScrapeDirectMessages.php b/app/Console/Commands/ScrapeDirectMessages.php
index 22a4885..ccdbc43 100644
--- a/app/Console/Commands/ScrapeDirectMessages.php
+++ b/app/Console/Commands/ScrapeDirectMessages.php
@@ -55,6 +55,7 @@ class ScrapeDirectMessages extends RunsPlaywrightScript
'shared_preview_image' => $message['share']['preview_image'] ?? null,
'shared_title' => $message['share']['title'] ?? null,
'shared_caption' => $message['share']['caption'] ?? null,
+ 'attachments' => $message['attachments'] ?? null,
'reactions' => $message['reactions'],
'replied_to_id' => $message['replied_to_id'],
'replied_to_text' => $message['replied_to_text'],
diff --git a/app/Http/Controllers/StoryController.php b/app/Http/Controllers/StoryController.php
index 7f2369e..c496ed4 100644
--- a/app/Http/Controllers/StoryController.php
+++ b/app/Http/Controllers/StoryController.php
@@ -69,4 +69,33 @@ class StoryController extends Controller
Story::where('id', $id)->update(['viewed_at' => now()]);
return response()->json(['ok' => true]);
}
+
+ /**
+ * Fetch a single story item by its own id, regardless of whether it's
+ * still active — used for opening a story someone shared in a DM long
+ * after it expired from the live tray.
+ */
+ public function showItem(string $id): JsonResponse
+ {
+ $item = Story::find($id);
+
+ if (! $item) {
+ return response()->json(['message' => 'Story not found.'], 404);
+ }
+
+ return response()->json([
+ 'username' => $item->username,
+ 'profile_pic_url' => $item->profile_pic_url,
+ 'start_index' => 0,
+ 'items' => [[
+ 'id' => $item->id,
+ 'code' => $item->code,
+ 'is_video' => $item->is_video,
+ 'image_url' => $item->image_url,
+ 'video_url' => $item->video_url,
+ 'accessibility_caption' => $item->accessibility_caption,
+ 'taken_at' => $item->taken_at,
+ ]],
+ ]);
+ }
}
diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php
index d047a12..78719cb 100644
--- a/app/Http/Controllers/ThreadController.php
+++ b/app/Http/Controllers/ThreadController.php
@@ -4,10 +4,25 @@ namespace App\Http\Controllers;
use App\Models\DirectMessage;
use App\Models\DirectThread;
+use App\Models\Story;
use Illuminate\Http\JsonResponse;
class ThreadController extends Controller
{
+ /**
+ * Instagram's own story permalinks embed both the poster's username and
+ * the story item's numeric pk: instagram.com/stories/{username}/{pk}.
+ * That pk is the same id our story scraper stores.
+ */
+ private function parseStorySharePk(?string $url): ?array
+ {
+ if (! $url || ! preg_match('#/stories/([^/?]+)/(\d+)#', $url, $matches)) {
+ return null;
+ }
+
+ return ['username' => $matches[1], 'item_id' => $matches[2]];
+ }
+
public function index(): JsonResponse
{
$threads = DirectThread::query()
@@ -40,10 +55,23 @@ class ThreadController extends Controller
{
$thread = DirectThread::findOrFail($threadId);
- $messages = $thread->messages()
- ->orderBy('timestamp')
- ->get()
- ->map(fn (DirectMessage $message) => [
+ $messages = $thread->messages()->orderBy('timestamp')->get();
+
+ // Batch-check which story shares actually still exist in our local
+ // `stories` table, so we only need one query for the whole thread
+ // rather than one per message.
+ $storyPks = $messages
+ ->map(fn (DirectMessage $m) => $this->parseStorySharePk($m->shared_url)['item_id'] ?? null)
+ ->filter()
+ ->unique()
+ ->values();
+
+ $existingStoryIds = Story::whereIn('id', $storyPks)->pluck('id')->all();
+
+ $messages = $messages->map(function (DirectMessage $message) use ($existingStoryIds) {
+ $storyShare = $this->parseStorySharePk($message->shared_url);
+
+ return [
'id' => $message->id,
'user_id' => $message->user_id,
'username' => $message->username,
@@ -55,10 +83,17 @@ class ThreadController extends Controller
'shared_preview_image' => $message->shared_preview_image,
'shared_title' => $message->shared_title,
'shared_caption' => $message->shared_caption,
+ 'attachments' => $message->attachments,
'reactions' => $message->reactions,
'replied_to_id' => $message->replied_to_id,
'replied_to_text' => $message->replied_to_text,
- ]);
+ 'story_share' => $storyShare ? [
+ 'username' => $storyShare['username'],
+ 'item_id' => $storyShare['item_id'],
+ 'exists_locally' => in_array($storyShare['item_id'], $existingStoryIds, true),
+ ] : null,
+ ];
+ });
return response()->json([
'thread_id' => $thread->thread_id,
diff --git a/app/Models/DirectMessage.php b/app/Models/DirectMessage.php
index 1ae3c5f..02976da 100644
--- a/app/Models/DirectMessage.php
+++ b/app/Models/DirectMessage.php
@@ -15,7 +15,7 @@ class DirectMessage extends Model
protected $fillable = [
'id', 'thread_id', 'user_id', 'username', 'full_name', 'item_type', 'text',
- 'media_url', 'is_video', 'timestamp', 'shared_url', 'shared_preview_image',
+ 'media_url', 'is_video', 'attachments', 'timestamp', 'shared_url', 'shared_preview_image',
'shared_title', 'shared_caption', 'reactions', 'replied_to_id', 'replied_to_text',
];
@@ -23,6 +23,7 @@ class DirectMessage extends Model
'is_video' => 'boolean',
'timestamp' => 'integer',
'reactions' => 'array',
+ 'attachments' => 'array',
'scraped_at' => 'datetime',
];
diff --git a/playwright/debugger.spec.js b/playwright/debugger.spec.js
index ca2ec83..5d65d86 100644
--- a/playwright/debugger.spec.js
+++ b/playwright/debugger.spec.js
@@ -1,11 +1,12 @@
import { test } from '@playwright/test';
+import path from 'path';
-test.use({ storageState: '/home/pwuser/ig-session.json' });
+test.use({ storageState: path.resolve(__dirname, '.auth', 'ig-session.json') });
-test('inspect non-text message shapes', async ({ page }) => {
+test('inspect IMAGES message shape', async ({ page }) => {
test.setTimeout(120_000);
- const interesting = [];
+ const found = [];
page.on('response', async (response) => {
const url = response.url();
@@ -17,10 +18,8 @@ test('inspect non-text message shapes', async ({ page }) => {
const t = json?.data?.get_slide_thread_nullable?.as_ig_direct_thread;
if (!t) return;
for (const edge of t.slide_messages?.edges || []) {
- const node = edge.node;
- const hasReactions = (node.reactions?.length || 0) > 0 || (node.msg_reactions?.length || 0) > 0;
- if (node.content_type !== 'TEXT' || hasReactions || node.replied_to_message) {
- interesting.push({ thread: t.thread_title, node });
+ if (edge.node?.content_type === 'IMAGES') {
+ found.push(edge.node);
}
}
} catch (e) { /* skip */ }
@@ -29,13 +28,14 @@ test('inspect non-text message shapes', async ({ page }) => {
await page.goto('https://www.instagram.com/direct/inbox/');
await page.waitForTimeout(6000);
- const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', els =>
- [...new Set(els.map(el => el.getAttribute('href')))]);
+ const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', (els) => [
+ ...new Set(els.map((el) => el.getAttribute('href'))),
+ ]);
for (const href of threadHrefs) {
await page.goto(`https://www.instagram.com${href}`);
await page.waitForTimeout(3500);
}
- console.log('[INTERESTING MESSAGES]', JSON.stringify(interesting, null, 2));
-});
\ No newline at end of file
+ console.log('[IMAGES MESSAGES]', JSON.stringify(found, null, 2));
+});
diff --git a/playwright/scrape-messages.spec.js b/playwright/scrape-messages.spec.js
index c0749fd..82bba1d 100644
--- a/playwright/scrape-messages.spec.js
+++ b/playwright/scrape-messages.spec.js
@@ -61,6 +61,18 @@ function extractShare(node) {
};
}
+// A raw photo/video message has content_type 'IMAGES' with one or more
+// attachments (Instagram allows sending several photos in a single message).
+function extractAttachments(node) {
+ const attachments = node.content?.attachments;
+ if (!attachments?.length) return null;
+ return attachments.map((a) => ({
+ url: a.attachment_cdn_url || a.preview_cdn_url || null,
+ width: a.preview_width || null,
+ height: a.preview_height || null,
+ })).filter((a) => a.url);
+}
+
function extractMessage(node, thread, fbidLookup) {
const senderDict = node.sender?.user_dict;
const resolved = senderDict
@@ -85,6 +97,7 @@ function extractMessage(node, thread, fbidLookup) {
text: node.text_body || node.content?.text_body || null,
timestamp_ms: node.timestamp_ms ? Number(node.timestamp_ms) : null,
share: extractShare(node),
+ attachments: extractAttachments(node),
reactions: reactions.length ? reactions : null,
replied_to_id: node.replied_to_message_id || null,
replied_to_text: node.replied_to_message?.text_body || null,
diff --git a/resources/js/components/dredge/Messages/MessageBubble.vue b/resources/js/components/dredge/Messages/MessageBubble.vue
index ef65915..00da2ec 100644
--- a/resources/js/components/dredge/Messages/MessageBubble.vue
+++ b/resources/js/components/dredge/Messages/MessageBubble.vue
@@ -2,6 +2,8 @@
import Avatar from '@/components/dredge/Avatar.vue';
import MessageText from './MessageText.vue';
import MessageShare from './MessageShare.vue';
+import MessageStoryShare from './MessageStoryShare.vue';
+import MessageMedia from './MessageMedia.vue';
import MessageSystem from './MessageSystem.vue';
import type { ThreadMessage } from '@/types/dredge.types';
@@ -26,9 +28,16 @@ defineProps<{
{{ message.replied_to_text }}
-
-
-
+
+
+
+
+
+
+
diff --git a/resources/js/components/dredge/Messages/MessageMedia.vue b/resources/js/components/dredge/Messages/MessageMedia.vue
new file mode 100644
index 0000000..ffd2d88
--- /dev/null
+++ b/resources/js/components/dredge/Messages/MessageMedia.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
diff --git a/resources/js/components/dredge/Messages/MessageStoryShare.vue b/resources/js/components/dredge/Messages/MessageStoryShare.vue
new file mode 100644
index 0000000..9583f29
--- /dev/null
+++ b/resources/js/components/dredge/Messages/MessageStoryShare.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+ {{ message.shared_title }}
+
+
+ {{ message.shared_caption }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/dredge/Stories/StoryViewer.vue b/resources/js/components/dredge/Stories/StoryViewer.vue
index a9828fc..4ddb6e4 100644
--- a/resources/js/components/dredge/Stories/StoryViewer.vue
+++ b/resources/js/components/dredge/Stories/StoryViewer.vue
@@ -6,6 +6,9 @@ import type { StoryDetail } from '@/types/dredge.types';
const props = defineProps<{
username: string;
+ // When set, opens this specific (possibly expired) story item directly
+ // instead of the user's current live tray.
+ itemId?: string;
}>();
const emit = defineEmits<{
@@ -30,12 +33,15 @@ async function fetchStory() {
loading.value = true;
error.value = null;
try {
- const response = await fetch(`/api/stories/${props.username}`);
+ const url = props.itemId ? `/api/story-items/${props.itemId}` : `/api/stories/${props.username}`;
+ const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load story');
detail.value = await response.json();
currentIndex.value = detail.value?.start_index ?? 0;
} catch (e) {
- error.value = 'Could not load this story.';
+ error.value = props.itemId
+ ? 'This story is no longer available.'
+ : 'Could not load this story.';
} finally {
loading.value = false;
}
diff --git a/resources/js/types/dredge.types.ts b/resources/js/types/dredge.types.ts
index 6a6edf7..2133438 100644
--- a/resources/js/types/dredge.types.ts
+++ b/resources/js/types/dredge.types.ts
@@ -78,6 +78,12 @@ export interface ThreadReaction {
username: string | null;
}
+export interface ThreadStoryShare {
+ username: string;
+ item_id: string;
+ exists_locally: boolean;
+}
+
export interface ThreadMessage {
id: string;
user_id: string | null;
@@ -90,9 +96,11 @@ export interface ThreadMessage {
shared_preview_image: string | null;
shared_title: string | null;
shared_caption: string | null;
+ attachments: { url: string; width: number | null; height: number | null }[] | null;
reactions: ThreadReaction[] | null;
replied_to_id: string | null;
replied_to_text: string | null;
+ story_share: ThreadStoryShare | null;
}
export interface ThreadDetail {
diff --git a/routes/api.php b/routes/api.php
index ececfb9..d4fc446 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -9,5 +9,6 @@ Route::get('/posts', [PostController::class, 'index']);
Route::get('/stories', [StoryController::class, 'index']);
Route::get('/stories/{username}', [StoryController::class, 'show']);
Route::post('/story-items/{id}/view', [StoryController::class, 'markViewed']);
+Route::get('/story-items/{id}', [StoryController::class, 'showItem']);
Route::get('/threads', [ThreadController::class, 'index']);
Route::get('/threads/{threadId}', [ThreadController::class, 'show']);