feat: Implemented fetching stories

Signed-off-by: Philipp Le <philipp@philipple.de>
Change-Id: I3841b5ca69f304c52c6bc6f32d3a0329b0547db4
diff --git a/src/config.example.ts b/src/config.example.ts
new file mode 100644
index 0000000..bbb587f
--- /dev/null
+++ b/src/config.example.ts
@@ -0,0 +1,23 @@
+
+// Put username here
+export const username = "";
+
+export const story_config = {
+    users: [
+        // Add users to watch here
+    ],
+    destination: "download",
+    email: {
+        smtp: {
+            host: "",       // SMTP host
+            port: 587,
+            user: "",       // SMTP user
+            password: "",   // SMTP password
+            starttls: true
+        },
+        from: "",
+        to: "",
+        subject: ""
+    },
+    sleep_sec: (2 * 3600)
+};
diff --git a/src/story.ts b/src/story.ts
new file mode 100644
index 0000000..2163555
--- /dev/null
+++ b/src/story.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ *   Copyright (c) 2022 Philipp Le <philipp@philipple.de>.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import nodemailer = require("nodemailer");
+
+import { username, story_config } from "./config";
+import {Client, StoryCollection, Sleeper} from "./utils"
+
+async function send_email_report(stories: StoryCollection) {
+    if ((stories.length() > 0) && (story_config.email)) {
+        const smtp = nodemailer.createTransport({
+            host: story_config.email.smtp.host,
+            port: story_config.email.smtp.port,
+            auth: {
+                user: story_config.email.smtp.user,
+                pass: story_config.email.smtp.password
+            },
+            requireTLS: story_config.email.smtp.starttls
+        });
+        let body = "IG Stories have been downloaded\n\nDetails:\n";
+        stories.users.forEach((stories, user) => {
+            if (stories.length > 0) {
+                body += `    Fetched ${stories.length} items for ${user}`
+            }
+        });
+        await smtp.sendMail({
+            from: story_config.email.from,
+            to: story_config.email.to,
+            subject: story_config.email.subject,
+            text: body
+        });
+        smtp.close();
+    }
+}
+
+const client = new Client();
+(async () => {
+    await client.login(username);
+    try {
+        const sleeper = new Sleeper();
+
+        let running = true;
+        process.on("SIGINT", (code) => {
+            running = false;
+            sleeper.cancel();
+        });
+
+        while (running) {
+            const stories = await client.get_stories(story_config.users);
+            await stories.download(story_config.destination);
+            await send_email_report(stories);
+            await sleeper.sleep_ms(story_config.sleep_sec * 1000);
+        }
+    } finally {
+        await client.logout();
+    }
+})();
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..df795b1
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,240 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ *   Copyright (c) 2022 Philipp Le <philipp@philipple.de>.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { IgApiClient, IgLoginTwoFactorRequiredError } from 'instagram-private-api';
+import Bluebird = require("bluebird");
+import inquirer = require ("inquirer");
+import date = require("date-and-time");
+import fs = require("fs");
+import path = require("path");
+import https = require("https");
+
+export class MediaItem {
+    id: string = "";
+    taken_at: Date = new Date();
+    date_str: string = "";
+    url: string = "";
+    filename: string = "";
+
+    constructor(id: string, taken_at: number, url: string) {
+        this.id = id;
+        this.taken_at = new Date(taken_at * 1000);
+        this.date_str = date.format(this.taken_at, "YYYYMMDD");
+        this.url = url;
+        const filename = url.split('#').shift()?.split('?').shift()?.split('/').pop();
+        if (filename)
+            this.filename = filename;
+        else
+            this.filename = "";
+    }
+
+    public async fetch(dest_dir: string): Promise<void> {
+        const dest_path = path.join(dest_dir, this.filename);
+        console.log(`Downloading ${this.url} => ${dest_path}`);
+        const file = fs.createWriteStream(dest_path);
+        await new Promise((resolve) => {
+            https.get(this.url, (response) => {
+                response.pipe(file);
+                file.on("finish", () => {
+                    file.close();
+                    resolve(response);
+                });
+            })
+        });
+        console.log(`Completed downloading ${this.url} => ${dest_path}`);
+    }
+}
+
+export class UserStory extends MediaItem {
+    constructor(id: string, taken_at: number, url: string) {
+        super(id, taken_at, url);
+    }
+}
+
+export class StoryCollection {
+    users: Map<string, UserStory[]> = new Map<string, UserStory[]>();
+
+    public length(): number {
+        let cntr = 0;
+        this.users.forEach(() => cntr++);
+        return cntr;
+    }
+
+    public async download(base_dir: string): Promise<void> {
+        await Bluebird.map(this.users.entries(), async (entry) => {
+            const user = entry[0];
+            const story_list = entry[1];
+
+            const user_dir = path.join(base_dir, user);
+            await this.mkdir(user_dir);
+
+            await Bluebird.map(story_list, async (story) => {
+                const story_dir = path.join(user_dir, story.date_str);
+                await this.mkdir(story_dir);
+                await story.fetch(story_dir);
+            });
+
+            await this.dump_story_json(user_dir, story_list);
+        });
+    }
+
+    private async mkdir(dir: string): Promise<void> {
+        await new Promise((resolve) => {
+            fs.mkdir(dir, {recursive: true}, (err) => {
+                if (err)
+                    console.error(err)
+                else
+                    console.log(`Created directory ${dir}`)
+                resolve(null);
+            });
+        });
+    }
+
+    private async dump_story_json(dest_dir: string, stories: UserStory[]): Promise<void> {
+        const d = date.format(new Date, "YYYY-MM-DDTHHmmssZ", true);
+        const dest_path = path.join(dest_dir, `info_${d}.json`);
+        await new Promise((resolve) => {
+            fs.writeFile(dest_path, JSON.stringify(stories), (err) => {
+                if (err)
+                    console.error(err)
+                else
+                    console.log(`Written info to ${dest_path}`)
+                resolve(null);
+            });
+        });
+    }
+}
+
+export class Client
+{
+    ig = new IgApiClient();
+
+    constructor(seed?: string) {
+        if (seed === undefined)
+            seed = `ig-story-dl-${date.format(new Date(), "YYYYMMDDHH")}`
+        this.ig.state.generateDevice(seed);
+    }
+
+    public async login(username: string) {
+        await this.ig.simulate.preLoginFlow();
+
+        const { passwd } = await inquirer.prompt([
+            {
+                type: 'password',
+                name: 'passwd',
+                message: `Enter password for ${username}`,
+            },
+        ]);
+
+        await Bluebird.try(() => this.ig.account.login(username, passwd)).catch(
+            IgLoginTwoFactorRequiredError,
+            async err => {
+                const {username, totp_two_factor_on, two_factor_identifier} = err.response.body.two_factor_info;
+                const verificationMethod: string = totp_two_factor_on ? '0' : '0';
+                const { code } = await inquirer.prompt([
+                    {
+                        type: 'input',
+                        name: 'code',
+                        message: `Enter code received via ${verificationMethod === '1' ? 'SMS' : 'TOTP'}`,
+                    },
+                ]);
+                return this.ig.account.twoFactorLogin({
+                    username,
+                    verificationCode: code,
+                    twoFactorIdentifier: two_factor_identifier,
+                    verificationMethod, // '1' = SMS (default), '0' = TOTP (google auth for example)
+                    trustThisDevice: '0',
+                });
+            },
+        ).catch(e => console.error('An error occurred while processing two factor auth', e, e.stack));
+
+        console.log(`Logged in as ${username}`);
+    }
+
+    public async logout() {
+        await this.ig.account.logout();
+        console.log(`Logged out`);
+    }
+
+    public async get_stories(users: string[]): Promise<StoryCollection> {
+        let stories = new StoryCollection();
+
+        await Bluebird.map(users, async (user) => {
+            let user_stories: UserStory[] = [];
+
+            const target_user = await this.ig.user.searchExact(user);
+            const reels_feed = this.ig.feed.reelsMedia({
+                userIds: [target_user.pk]
+            });
+            console.info(`Fetch stories for ${target_user.pk} (${target_user.username})`)
+            const story_items = await reels_feed.items();
+            story_items.forEach((item) => {
+                console.info(`${target_user.pk} (${target_user.username}) story item: ${item.id}`)
+                if (item.image_versions2) {
+                    item.image_versions2.candidates.forEach((pic) => {
+                        user_stories.push(
+                            new UserStory(
+                                item.id,
+                                item.taken_at,
+                                pic.url
+                            )
+                        );
+                        console.info(`${target_user.pk} (${target_user.username})->${item.id}: ${pic.url}`)
+                    });
+                }
+                if (item.video_versions) {
+                    item.video_versions.forEach((vid) => {
+                        user_stories.push(
+                            new UserStory(
+                                item.id,
+                                item.taken_at,
+                                vid.url
+                            )
+                        );
+                        console.info(`${target_user.pk} (${target_user.username})->${item.id}: ${vid.url}`)
+                    });
+                }
+            });
+
+            stories.users.set(user, user_stories);
+        });
+
+        return stories;
+    }
+}
+
+export function json_replacer(key: any, value: any) {
+    if (value instanceof Map) {
+        return Array.from(value.entries());
+    } else {
+        return value;
+    }
+}
+
+export class Sleeper {
+    running: boolean = false;
+
+    poll_interval: number = 1000;
+
+    constructor(poll_interval: number = 1000) {
+        this.poll_interval = poll_interval;
+    }
+
+    public async sleep_ms(ms: number): Promise<void> {
+        this.running = true;
+        for (let remaining_ms = ms; (remaining_ms > 0) && this.running; remaining_ms -= this.poll_interval) {
+            await new Promise((resolve) => {
+                setTimeout(resolve, (remaining_ms > this.poll_interval) ? this.poll_interval : remaining_ms)
+            });
+        }
+    }
+
+    public cancel() {
+        this.running = false;
+    }
+}