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