Misskey Remote Follow Helper

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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