Misskey Remote Follow Helper

Remote follow the user of the current page on your Misskey instance via a Tampermonkey menu item.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Misskey Remote Follow Helper
// @namespace    https://controlnet.space/
// @version      0.1
// @description  Remote follow the user of the current page on your Misskey instance via a Tampermonkey menu item.
// @author       ControlNet
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      *
// @license      agpl-3.0
// ==/UserScript==

(function () {
    'use strict';

    /******************************************************************
     * CONFIG STORAGE (user input via menu)
     ******************************************************************/
    const KEY_INSTANCE = 'mrh_instance_host';
    const KEY_TOKEN    = 'mrh_api_token';

    function getInstance() {
        return GM_getValue(KEY_INSTANCE, '');
    }

    function getToken() {
        return GM_getValue(KEY_TOKEN, '');
    }

    function setInstance() {
        const current = getInstance() || '';
        const input = prompt(
            'Enter your Misskey instance URL (required, e.g. https://misskey.io):',
            current
        );
        if (input !== null) {
            const trimmed = input.trim().replace(/\/$/, ''); // remove trailing slash
            if (trimmed) {
                GM_setValue(KEY_INSTANCE, trimmed);
                alert('[Misskey Remote Follow]\nInstance saved:\n' + trimmed);
            } else {
                alert('[Misskey Remote Follow]\nInstance not changed (empty).');
            }
        }
    }

    function setToken() {
        const current = getToken() || '';
        const input = prompt(
            'Enter your Misskey API token (required; stored locally in Tampermonkey):',
            current
        );
        if (input !== null) {
            const trimmed = input.trim();
            if (trimmed) {
                GM_setValue(KEY_TOKEN, trimmed);
                alert('[Misskey Remote Follow]\nAPI token saved.');
            } else {
                alert('[Misskey Remote Follow]\nToken not changed (empty).');
            }
        }
    }

    function ensureConfig() {
        const inst = (getInstance() || '').trim();
        const tok  = (getToken() || '').trim();

        if (!inst && !tok) {
            throw new Error(
                '[Misskey Remote Follow] Misskey instance and API token are not set.\n' +
                'Use Tampermonkey menu:\n' +
                '  - "Set Misskey instance"\n' +
                '  - "Set Misskey API token"'
            );
        }
        if (!inst) {
            throw new Error(
                '[Misskey Remote Follow] Misskey instance is not set.\n' +
                'Use Tampermonkey menu: "Set Misskey instance".'
            );
        }
        if (!tok) {
            throw new Error(
                '[Misskey Remote Follow] Misskey API token is not set.\n' +
                'Use Tampermonkey menu: "Set Misskey API token".'
            );
        }
        return true;
    }

    /******************************************************************
     * UTIL: Misskey API wrapper
     ******************************************************************/
    function misskeyApi(path, payload) {
        const HOME_INSTANCE = getInstance().trim();
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: HOME_INSTANCE.replace(/\/$/, '') + path,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(payload),
                onload: (res) => {
                    if (res.status >= 200 && res.status < 300) {
                        try {
                            const json = JSON.parse(res.responseText);
                            resolve(json);
                        } catch (e) {
                            reject(new Error('Failed to parse JSON: ' + e.message));
                        }
                    } else {
                        reject(new Error('HTTP ' + res.status + ' ' + res.responseText));
                    }
                },
                onerror: (err) => {
                    reject(new Error('Network error: ' + JSON.stringify(err)));
                }
            });
        });
    }

    /******************************************************************
     * UTIL: Extract remote user from URL
     *
     * Format 1: https://${remote_host}/@${remote_username}
     *   - Example: https://remote.host/@user
     *   => host = remote.host (from URL), username = user
     *
     * Format 2: https://xxx.yyy/@${remote_username}@${remote_host}
     *   - Example: https://mastodon.example/@[email protected]
     *   => username = alice, host = social.controlnet.space
     ******************************************************************/
    function extractFromUrl(urlString) {
        let url;
        try {
            url = new URL(urlString);
        } catch (e) {
            return null;
        }

        const path = url.pathname;

        // Match /@something
        const m = path.match(/^\/@([^\/]+)$/);
        if (!m) {
            // OPTIONAL: /users/username support (delete if unwanted)
            const userMatch = path.match(/^\/users\/([^\/]+)$/);
            if (userMatch) {
                return {
                    host: url.hostname,
                    username: userMatch[1],
                    source: 'url-users'
                };
            }
            return null;
        }

        const handle = m[1]; // "user" OR "user@host"

        // If no inner '@', it's format 1: https://${remote_host}/@${remote_username}
        if (!handle.includes('@')) {
            return {
                host: url.hostname,
                username: handle,
                source: 'url-format1'
            };
        }

        // Format 2: strictly username first, host second
        // handle = "${remote_username}@${remote_host}"
        const parts = handle.split('@').filter(Boolean);
        if (parts.length < 2) {
            return null;
        }

        const username = parts[0];
        const host = parts.slice(1).join('@'); // mostly parts[1], but join for safety

        return {
            host,
            username,
            source: 'url-format2'
        };
    }

    function getRemoteUserInfo() {
        return extractFromUrl(location.href);
    }

    /******************************************************************
     * MAIN: remote follow
     ******************************************************************/
    async function remoteFollowCurrentUser() {
        try {
            ensureConfig();
        } catch (err) {
            alert(err.message);
            console.error(err);
            return;
        }

        const MISSKEY_TOKEN = getToken().trim();
        const info = getRemoteUserInfo();

        if (!info) {
            const errMsg =
                '[Misskey Remote Follow]\n' +
                'Could not detect a user from this page.\n\n' +
                'Supported URL formats:\n' +
                '  1) https://${remote_host}/@${remote_username}\n' +
                '  2) https://xxx.yyy/@${remote_username}@${remote_host}\n';
            alert(errMsg);
            console.error(new Error(errMsg));
            return;
        }

        const remoteHandle = `${info.username}@${info.host}`;
        console.log('[Misskey Remote Follow] Parsed remote user:', info);

        try {
            // 1) Resolve / ensure user exists on your instance
            const user = await misskeyApi('/api/users/show', {
                i: MISSKEY_TOKEN,
                username: info.username,
                host: info.host
            });

            if (!user || !user.id) {
                throw new Error('users/show did not return a user id');
            }

            // 2) Send follow
            const followRes = await misskeyApi('/api/following/create', {
                i: MISSKEY_TOKEN,
                userId: user.id
            });

            console.log('[Misskey Remote Follow] follow result:', followRes);
            alert(
                `[Misskey Remote Follow]\n` +
                `Sent follow request to ${remoteHandle}.`
            );
        } catch (err) {
            console.error('[Misskey Remote Follow] error:', err);
            alert(
                '[Misskey Remote Follow]\nFailed to follow ' +
                remoteHandle + ':\n' + err.message
            );
        }
    }

    /******************************************************************
     * Tampermonkey menu
     ******************************************************************/
    GM_registerMenuCommand('Remote follow on Misskey', remoteFollowCurrentUser);
    GM_registerMenuCommand('Set Misskey instance', setInstance);
    GM_registerMenuCommand('Set Misskey API token', setToken);
})();