// ==UserScript==
// @name ChatGPT Copy Tokens
// @namespace https://github.com/qFamouse/
// @version 1.1
// @description Two ChatGPT-styled buttons to copy the accessToken and refreshToken (sessionToken) of the current session
// @description:ru Две аккуратные кнопки в стиле ChatGPT для копирования accessToken и refreshToken (sessionToken) текущей сессии
// @author Famouse
// @license MIT
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @grant GM_setClipboard
// @run-at document-idle
// @homepageURL https://github.com/qFamouse/Userscripts/tree/master/scripts/chatgpt-copy-tokens
// @supportURL https://github.com/qFamouse/Userscripts/issues
// ==/UserScript==
(function () {
'use strict';
const HOST_ID = 'tm-copy-tokens';
// ---------- иконки (lucide) ----------
const svg = (inner, extra = '') =>
``;
const ICONS = {
key: svg(''),
refresh: svg(''),
check: svg(''),
alert: svg(''),
spinner: svg('', 'class="spin"'),
};
// ---------- получение сессии ----------
async function getSession() {
const res = await fetch('/api/auth/session', {
credentials: 'include',
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error('Ошибка запроса: HTTP ' + res.status);
const data = await res.json();
if (!data || !data.accessToken) throw new Error('Вы не авторизованы');
return data;
}
// ---------- копирование ----------
async function copyText(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
return;
}
try {
await navigator.clipboard.writeText(text);
} catch (_) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
// ---------- действия ----------
async function copyAccessToken() {
const s = await getSession();
await copyText(s.accessToken);
return 'accessToken скопирован';
}
async function copyRefreshToken() {
const s = await getSession();
// В веб-версии ChatGPT отдельного refreshToken нет — его роль играет sessionToken.
const field = s.refreshToken ? 'refreshToken'
: s.refresh_token ? 'refresh_token'
: s.sessionToken ? 'sessionToken'
: null;
if (!field) throw new Error('В сессии нет refresh/session токена');
await copyText(s[field]);
return field + ' скопирован';
}
// ---------- стили (Shadow DOM, чтобы не конфликтовать со стилями сайта) ----------
const CSS = `
:host {
position: fixed;
right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 2147483647;
}
.wrap {
--bg: #ffffff;
--bg-hover: #f3f3f3;
--bg-press: #e8e8e8;
--border: rgba(0, 0, 0, .12);
--text: #0d0d0d;
--icon: #5d5d5d;
--shadow: 0 4px 16px rgba(0, 0, 0, .10), 0 1px 3px rgba(0, 0, 0, .06);
--ok: #10a37f;
--err: #ef4444;
--hint-bg: #0d0d0d;
--hint-text: #ffffff;
position: relative;
display: flex;
gap: 8px;
font-family: inherit;
animation: appear .25s ease-out both;
}
.wrap[data-theme="dark"] {
--bg: #2f2f2f;
--bg-hover: #3a3a3a;
--bg-press: #444444;
--border: rgba(255, 255, 255, .12);
--text: #ececec;
--icon: #b4b4b4;
--shadow: 0 4px 18px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .3);
--hint-bg: #ececec;
--hint-text: #0d0d0d;
}
button {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px 0 12px;
border-radius: 9999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
cursor: pointer;
user-select: none;
box-shadow: var(--shadow);
transition: background-color .15s ease, border-color .15s ease,
color .15s ease, transform .1s ease;
}
button:hover { background: var(--bg-hover); }
button:active { background: var(--bg-press); transform: scale(.97); }
button:focus-visible { outline: 2px solid var(--ok); outline-offset: 2px; }
button[data-busy] { cursor: default; }
.icon { display: inline-flex; color: var(--icon); transition: color .15s ease; }
button[data-state="ok"] { border-color: var(--ok); }
button[data-state="ok"] .icon { color: var(--ok); }
button[data-state="error"] { border-color: var(--err); }
button[data-state="error"] .icon { color: var(--err); }
.spin { animation: spin .8s linear infinite; }
.hint {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
padding: 6px 10px;
border-radius: 8px;
background: var(--hint-bg);
color: var(--hint-text);
font-size: 13px;
line-height: 18px;
white-space: nowrap;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition: opacity .18s ease, transform .18s ease;
}
.hint[data-show] { opacity: 1; transform: none; }
.hint[data-error] { background: var(--err); color: #fff; }
@keyframes appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 640px) {
:host { right: 12px; bottom: calc(92px + env(safe-area-inset-bottom, 0px)); }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
`;
// ---------- тема ----------
function isDark() {
const cl = document.documentElement.classList;
if (cl.contains('dark')) return true;
if (cl.contains('light')) return false;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// ---------- сборка UI ----------
function mount() {
if (!document.body || document.getElementById(HOST_ID)) return;
const host = document.createElement('div');
host.id = HOST_ID;
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
const wrap = document.createElement('div');
wrap.className = 'wrap';
wrap.dataset.theme = isDark() ? 'dark' : 'light';
const hint = document.createElement('div');
hint.className = 'hint';
hint.setAttribute('role', 'status');
hint.setAttribute('aria-live', 'polite');
let hintTimer;
function showHint(text, isError) {
hint.textContent = text;
if (isError) hint.setAttribute('data-error', ''); else hint.removeAttribute('data-error');
hint.setAttribute('data-show', '');
clearTimeout(hintTimer);
hintTimer = setTimeout(() => hint.removeAttribute('data-show'), 2600);
}
function makeButton({ icon, label, title, action }) {
const btn = document.createElement('button');
btn.type = 'button';
btn.title = title;
btn.innerHTML = '';
const iconEl = btn.querySelector('.icon');
const labelEl = btn.querySelector('.label');
const states = {
idle: { icon: ICONS[icon], label },
loading: { icon: ICONS.spinner, label },
ok: { icon: ICONS.check, label: 'Скопировано' },
error: { icon: ICONS.alert, label: 'Ошибка' },
};
function setState(name) {
btn.dataset.state = name;
iconEl.innerHTML = states[name].icon;
labelEl.textContent = states[name].label;
}
setState('idle');
let resetTimer;
btn.addEventListener('click', async () => {
if (btn.hasAttribute('data-busy')) return;
btn.setAttribute('data-busy', '');
clearTimeout(resetTimer);
setState('loading');
let delay = 1800;
try {
const msg = await action();
setState('ok');
showHint(msg, false);
} catch (e) {
setState('error');
showHint(e.message || 'Не удалось скопировать', true);
delay = 2600;
}
resetTimer = setTimeout(() => {
setState('idle');
btn.removeAttribute('data-busy');
}, delay);
});
return btn;
}
wrap.append(
hint,
makeButton({
icon: 'key',
label: 'accessToken',
title: 'Скопировать accessToken',
action: copyAccessToken,
}),
makeButton({
icon: 'refresh',
label: 'refreshToken',
title: 'Скопировать refreshToken (в веб-версии это sessionToken)',
action: copyRefreshToken,
}),
);
root.append(style, wrap);
document.body.appendChild(host);
// следим за переключением светлой/тёмной темы сайта
new MutationObserver(() => {
wrap.dataset.theme = isDark() ? 'dark' : 'light';
}).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] });
}
mount();
// ChatGPT — SPA: если DOM перерисовался и кнопки пропали, возвращаем их
setInterval(mount, 2000);
})();