TheOrb/archive_bot/discord_api.py

673 lines
30 KiB
Python

from __future__ import annotations
import asyncio
import json
import secrets
import sys
import threading
import urllib.error
import urllib.request
from typing import Any
from .core import BotRuntime, DISCORD_API
from .storage import channel_settings, load_state, save_state
from .watchparty import (
add_watch_party_queue_item,
clear_watch_party_queue,
control_watch_party_worker,
create_watch_party_session,
end_watch_party_session,
find_watch_party_session,
get_watch_party_session,
list_media_candidates,
play_next_watch_party_item,
refresh_watch_party_worker,
start_watch_party_worker,
)
from .worker_client import worker_enabled
WATCH_PARTY_ACTIVE_STATUSES = {"draft", "queued", "connecting", "playing", "paused"}
WATCH_PARTY_PANEL_MESSAGE_KEY = "watch_party_panel_message_id"
WATCH_PARTY_PANEL_CREATE_ID = "watchparty_panel:create"
WATCH_PARTY_PANEL_ADD_ID = "watchparty_panel:add"
WATCH_PARTY_PANEL_START_ID = "watchparty_panel:start"
WATCH_PARTY_PANEL_PAUSE_ID = "watchparty_panel:pause"
WATCH_PARTY_PANEL_RESUME_ID = "watchparty_panel:resume"
WATCH_PARTY_PANEL_STOP_ID = "watchparty_panel:stop"
WATCH_PARTY_PANEL_QUEUE_ID = "watchparty_panel:queue"
WATCH_PARTY_PANEL_STATUS_ID = "watchparty_panel:status"
WATCH_PARTY_PANEL_CLEAR_QUEUE_ID = "watchparty_panel:clear_queue"
WATCH_PARTY_PANEL_END_SESSION_ID = "watchparty_panel:end_session"
def format_watch_party_summary(payload: dict[str, Any]) -> str:
session = payload.get("session", {})
worker = payload.get("worker") or {}
queue = payload.get("queue") or []
lines = [
f"Session `{session.get('title', 'Watch Party')}`",
f"Status: `{session.get('status', 'unknown')}`",
f"Worker: `{worker.get('workerStatus', 'idle')}` · Playback: `{worker.get('playbackState', 'idle')}`",
]
last_error = str(worker.get("lastError", "") or "").strip()
if last_error:
lines.append(f"Error: `{last_error}`")
current_title = str(worker.get("currentTitle", "") or "").strip()
if current_title:
lines.append(f"Now playing: `{current_title}`")
if queue:
preview = []
for entry in queue[:5]:
preview.append(f"{entry.get('position', '?')}. {entry.get('title', 'Untitled')} [{entry.get('status', 'queued')}]")
lines.append("Queue:\n" + "\n".join(preview))
if len(queue) > 5:
lines.append(f"...and {len(queue) - 5} more")
else:
lines.append("Queue: empty")
return "\n".join(lines)
def format_media_search_results(items: list[dict[str, Any]]) -> str:
if not items:
return "No matches found."
lines: list[str] = []
for item in items[:8]:
details = [str(item.get("mediaType", "")), str(item.get("year", "")), str(item.get("genres", ""))]
meta = " · ".join(part for part in details if part)
lines.append(f"- `{item.get('title', 'Untitled')}`{f'{meta}' if meta else ''}")
return "\n".join(lines)
def watch_party_panel_payload() -> dict[str, Any]:
return {
"embeds": [
{
"title": "Watch Party Controls",
"description": (
"Join a voice channel, then use the buttons below.\n\n"
"`Create` makes a session for your current VC.\n"
"`Add` opens a prompt for a Jellyfin title.\n"
"`Start` connects the worker and plays the next queued item."
),
"color": 0x8B5CF6,
}
],
"components": [
{
"type": 1,
"components": [
{"type": 2, "style": 1, "label": "Create", "custom_id": WATCH_PARTY_PANEL_CREATE_ID},
{"type": 2, "style": 1, "label": "Add", "custom_id": WATCH_PARTY_PANEL_ADD_ID},
{"type": 2, "style": 3, "label": "Start", "custom_id": WATCH_PARTY_PANEL_START_ID},
{"type": 2, "style": 2, "label": "Queue", "custom_id": WATCH_PARTY_PANEL_QUEUE_ID},
{"type": 2, "style": 2, "label": "Status", "custom_id": WATCH_PARTY_PANEL_STATUS_ID},
],
},
{
"type": 1,
"components": [
{"type": 2, "style": 2, "label": "Pause", "custom_id": WATCH_PARTY_PANEL_PAUSE_ID},
{"type": 2, "style": 2, "label": "Resume", "custom_id": WATCH_PARTY_PANEL_RESUME_ID},
{"type": 2, "style": 2, "label": "Stop", "custom_id": WATCH_PARTY_PANEL_STOP_ID},
{"type": 2, "style": 2, "label": "Clear Queue", "custom_id": WATCH_PARTY_PANEL_CLEAR_QUEUE_ID},
{"type": 2, "style": 4, "label": "End Session", "custom_id": WATCH_PARTY_PANEL_END_SESSION_ID},
],
},
],
}
def publish_watch_party_panel(runtime: BotRuntime, token: str) -> dict[str, Any]:
settings = channel_settings(runtime)
channel_id = str(settings.get("watchPartyChannelId", "")).strip()
if not channel_id:
raise ValueError("Watch-party channel ID is not configured")
state = load_state(runtime.settings_path)
payload = watch_party_panel_payload()
message_id = str(state.get(WATCH_PARTY_PANEL_MESSAGE_KEY, "")).strip()
if message_id:
try:
message = discord_request("PATCH", token, f"/channels/{channel_id}/messages/{message_id}", payload)
except RuntimeError as exc:
if "failed: 404" not in str(exc):
raise
message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload)
else:
message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload)
state[WATCH_PARTY_PANEL_MESSAGE_KEY] = str(message.get("id", "")).strip()
save_state(runtime.settings_path, state)
return {
"channelId": channel_id,
"messageId": state[WATCH_PARTY_PANEL_MESSAGE_KEY],
}
class DiscordGatewayManager:
def __init__(self, token: str, runtime: BotRuntime) -> None:
self.token = token
self.runtime = runtime
self.thread: threading.Thread | None = None
self.loop: asyncio.AbstractEventLoop | None = None
self.client: Any = None
self.ready = threading.Event()
self._disconnecting = threading.Event()
self._commands_registered = False
self._commands_synced = False
def start(self) -> None:
if self.thread is not None:
return
self.thread = threading.Thread(target=self._run, name="discord-gateway", daemon=True)
self.thread.start()
def stop(self) -> None:
self._disconnecting.set()
if self.loop is not None and self.client is not None:
asyncio.run_coroutine_threadsafe(self.client.close(), self.loop)
if self.thread is not None:
self.thread.join(timeout=15)
def _run(self) -> None:
try:
import discord
except ImportError:
print(
"discord.py is not installed. Install requirements.txt to keep the bot online.",
file=sys.stderr,
flush=True,
)
self.ready.set()
return
class GatewayClient(discord.Client):
def __init__(self, manager: DiscordGatewayManager) -> None:
intents = discord.Intents.default()
intents.guilds = True
intents.voice_states = True
super().__init__(intents=intents)
self.manager = manager
self.tree = discord.app_commands.CommandTree(self)
async def setup_hook(self) -> None:
self.manager.register_watchparty_commands(self.tree, discord)
async def on_ready(self) -> None:
await self.change_presence(status=discord.Status.online)
user = self.user
name = user.name if user is not None else "unknown"
bot_id = user.id if user is not None else "unknown"
await self.manager.sync_commands(self.tree, discord)
print(f"Discord gateway connected as {name} ({bot_id})", flush=True)
self.manager.ready.set()
async def on_disconnect(self) -> None:
self.manager.ready.clear()
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.client = GatewayClient(self)
try:
self.loop.run_until_complete(self.client.start(self.token))
except Exception as exc:
if not self._disconnecting.is_set():
print(f"Discord gateway stopped: {exc}", file=sys.stderr, flush=True)
finally:
self.ready.set()
try:
pending = asyncio.all_tasks(self.loop)
for task in pending:
task.cancel()
if pending:
self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
finally:
self.loop.close()
async def sync_commands(self, tree: Any, discord: Any) -> None:
if self._commands_synced:
return
try:
await tree.sync()
for guild in self.client.guilds if self.client is not None else []:
tree.copy_global_to(guild=guild)
await tree.sync(guild=guild)
except Exception as exc:
print(f"Discord command sync failed: {exc}", file=sys.stderr, flush=True)
else:
self._commands_synced = True
def register_watchparty_commands(self, tree: Any, discord: Any) -> None:
if self._commands_registered:
return
runtime = self.runtime
def current_voice_channel(interaction: Any) -> Any | None:
user = interaction.user
voice = getattr(user, "voice", None)
return getattr(voice, "channel", None)
def ensure_guild_context(interaction: Any) -> None:
if interaction.guild is None:
raise ValueError("This command only works inside a server")
def ensure_voice_context(interaction: Any) -> Any:
ensure_guild_context(interaction)
voice_channel = current_voice_channel(interaction)
if voice_channel is None:
raise ValueError("Join a voice channel first")
return voice_channel
def session_for_voice(interaction: Any) -> dict[str, Any] | None:
voice_channel = current_voice_channel(interaction)
if interaction.guild is None or voice_channel is None:
return None
session = find_watch_party_session(
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
allowed_statuses=WATCH_PARTY_ACTIVE_STATUSES,
)
if session is not None:
return session
return find_watch_party_session(
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
allowed_statuses=None,
)
def ensure_session(interaction: Any, *, create_if_missing: bool = False, title: str = "Watch Party") -> dict[str, Any]:
ensure_guild_context(interaction)
voice_channel = ensure_voice_context(interaction)
session = session_for_voice(interaction)
if session is not None:
if not create_if_missing or session.get("status") not in {"stopped", "error"}:
return session
if not create_if_missing:
raise ValueError("No watch-party session exists for your voice channel. Run /watchparty create first.")
payload = create_watch_party_session(
runtime,
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
text_channel_id=str(interaction.channel_id),
owner_user_id=str(interaction.user.id),
title=title,
)
return payload["session"]
def pick_media_item(query: str, media_type: str) -> dict[str, Any]:
items = list_media_candidates(runtime, media_type=media_type, search=query, limit=8)
if not items:
raise ValueError("No library matches found for that query")
exact_matches = [item for item in items if str(item.get("title", "")).casefold() == query.strip().casefold()]
if len(items) > 1 and not exact_matches:
preview = format_media_search_results(items[:5])
raise ValueError(f"Query matched multiple titles. Refine it:\n{preview}")
return exact_matches[0] if exact_matches else items[0]
async def respond(interaction: Any, message: str) -> None:
if interaction.response.is_done():
await interaction.followup.send(message, ephemeral=True)
else:
await interaction.response.send_message(message, ephemeral=True)
async def defer_then(interaction: Any, action) -> None:
if not interaction.response.is_done():
await interaction.response.defer(ephemeral=True)
await action()
async def do_create(interaction: Any, title: str | None = None) -> None:
voice_channel = ensure_voice_context(interaction)
session = session_for_voice(interaction)
if session is None or session.get("status") in {"stopped", "error"}:
payload = create_watch_party_session(
runtime,
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
text_channel_id=str(interaction.channel_id),
owner_user_id=str(interaction.user.id),
title=(title or f"{voice_channel.name} Watch Party").strip(),
)
session = payload["session"]
message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>."
else:
message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>."
await respond(interaction, message)
async def do_add(interaction: Any, query: str, media_type: str = "all") -> None:
session = ensure_session(interaction, create_if_missing=True, title="Watch Party")
choice = media_type.strip().lower() if media_type.strip().lower() in {"all", "movie", "show"} else "all"
item = pick_media_item(query, choice)
payload = add_watch_party_queue_item(runtime, int(session["id"]), str(item["sourceId"]))
await respond(
interaction,
f"Added `{item['title']}` to `{payload['session']['title']}`. Queue size: {len(payload['queue'])}.",
)
async def do_start(interaction: Any) -> None:
session = ensure_session(interaction, create_if_missing=False)
if not worker_enabled():
raise ValueError("Watch-party worker is not configured")
worker_payload = start_watch_party_worker(int(session["id"]))
try:
payload = play_next_watch_party_item(runtime, int(session["id"]))
await respond(
interaction,
f"Started `{payload['session']['title']}` in <#{payload['session']['voiceChannelId']}>.\n"
f"Now playing `{payload['worker'].get('currentTitle') or payload['session']['title']}`.",
)
except ValueError as exc:
if "No queued items available" not in str(exc):
raise
await respond(
interaction,
f"Connected the worker for `{worker_payload['session']['title']}`, but the queue is empty.",
)
async def do_control(interaction: Any, action: str, data: dict[str, Any] | None = None) -> None:
session = ensure_session(interaction, create_if_missing=False)
payload = control_watch_party_worker(int(session["id"]), action, data or {})
await respond(interaction, format_watch_party_summary(payload))
async def do_queue(interaction: Any) -> None:
session = ensure_session(interaction, create_if_missing=False)
payload = get_watch_party_session(int(session["id"]))
await respond(interaction, format_watch_party_summary(payload))
async def do_status(interaction: Any) -> None:
session = ensure_session(interaction, create_if_missing=False)
payload = refresh_watch_party_worker(int(session["id"])) if worker_enabled() else get_watch_party_session(int(session["id"]))
await respond(interaction, format_watch_party_summary(payload))
async def do_clear_queue(interaction: Any) -> None:
session = ensure_session(interaction, create_if_missing=False)
payload = clear_watch_party_queue(int(session["id"]))
await respond(
interaction,
f"Cleared all queued items from `{payload['session']['title']}`. Queue size: {len(payload['queue'])}.",
)
async def do_end_session(interaction: Any) -> None:
session = ensure_session(interaction, create_if_missing=False)
end_watch_party_session(int(session["id"]))
await respond(
interaction,
f"Ended the watch-party session `{session['title']}`.",
)
class WatchPartyAddModal(discord.ui.Modal, title="Add To Watch Party"):
query = discord.ui.TextInput(label="Title", placeholder="Alien", required=True, max_length=120)
media_type = discord.ui.TextInput(
label="Type",
placeholder="all, movie, or show",
required=False,
default="all",
max_length=16,
)
async def on_submit(self, interaction: Any) -> None:
try:
await defer_then(interaction, lambda: do_add(interaction, str(self.query.value), str(self.media_type.value or "all")))
except Exception as exc:
await respond(interaction, str(exc))
class WatchPartyPanelView(discord.ui.View):
def __init__(self) -> None:
super().__init__(timeout=None)
@discord.ui.button(label="Create", style=discord.ButtonStyle.primary, custom_id=WATCH_PARTY_PANEL_CREATE_ID)
async def create_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_create(interaction, None))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Add", style=discord.ButtonStyle.primary, custom_id=WATCH_PARTY_PANEL_ADD_ID)
async def add_button(self, interaction: Any, _button: Any) -> None:
await interaction.response.send_modal(WatchPartyAddModal())
@discord.ui.button(label="Start", style=discord.ButtonStyle.success, custom_id=WATCH_PARTY_PANEL_START_ID)
async def start_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_start(interaction))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Queue", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_QUEUE_ID)
async def queue_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_queue(interaction))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Status", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_STATUS_ID)
async def status_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_status(interaction))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Pause", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_PAUSE_ID, row=1)
async def pause_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_control(interaction, "pause", {}))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Resume", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_RESUME_ID, row=1)
async def resume_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_control(interaction, "resume", {}))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Stop", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_STOP_ID, row=1)
async def stop_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_control(interaction, "stop", {}))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="Clear Queue", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_CLEAR_QUEUE_ID, row=1)
async def clear_queue_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_clear_queue(interaction))
except Exception as exc:
await respond(interaction, str(exc))
@discord.ui.button(label="End Session", style=discord.ButtonStyle.danger, custom_id=WATCH_PARTY_PANEL_END_SESSION_ID, row=1)
async def end_session_button(self, interaction: Any, _button: Any) -> None:
try:
await defer_then(interaction, lambda: do_end_session(interaction))
except Exception as exc:
await respond(interaction, str(exc))
group = discord.app_commands.Group(name="watchparty", description="Control Jellyfin watch parties in Discord")
@group.command(name="create", description="Create a watch-party session for your current voice channel")
@discord.app_commands.describe(title="Optional session title")
async def create_command(interaction: Any, title: str | None = None) -> None:
try:
await do_create(interaction, title)
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="search", description="Search the saved Jellyfin library for watch-party titles")
@discord.app_commands.describe(query="Movie or show title", media_type="Limit the search to movies or shows")
async def search_command(interaction: Any, query: str, media_type: str = "all") -> None:
try:
ensure_guild_context(interaction)
choice = media_type.strip().lower() if media_type.strip().lower() in {"all", "movie", "show"} else "all"
items = list_media_candidates(runtime, media_type=choice, search=query, limit=8)
await respond(interaction, format_media_search_results(items))
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="add", description="Add a Jellyfin title to the current voice channel watch-party queue")
@discord.app_commands.describe(query="Exact or refined movie/show title", media_type="Limit the search to movies or shows")
async def add_command(interaction: Any, query: str, media_type: str = "all") -> None:
try:
await do_add(interaction, query, media_type)
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="start", description="Connect the worker and start playback in your current voice channel")
async def start_command(interaction: Any) -> None:
try:
await do_start(interaction)
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="pause", description="Pause the current watch-party stream")
async def pause_command(interaction: Any) -> None:
try:
await do_control(interaction, "pause", {})
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="resume", description="Resume the current watch-party stream")
async def resume_command(interaction: Any) -> None:
try:
await do_control(interaction, "resume", {})
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="stop", description="Stop the current watch-party stream")
async def stop_command(interaction: Any) -> None:
try:
await do_control(interaction, "stop", {})
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="seek", description="Seek the current stream to a time offset in seconds")
@discord.app_commands.describe(position_seconds="Target playback position in seconds")
async def seek_command(interaction: Any, position_seconds: int) -> None:
try:
await do_control(interaction, "seek", {"positionSeconds": max(0, position_seconds)})
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="queue", description="Show the queue for your current voice channel watch party")
async def queue_command(interaction: Any) -> None:
try:
await do_queue(interaction)
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="status", description="Refresh and show the worker status for your current voice channel watch party")
async def status_command(interaction: Any) -> None:
try:
await do_status(interaction)
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="clear", description="Clear the watch-party queue")
async def clear_command(interaction: Any) -> None:
try:
await do_clear_queue(interaction)
except Exception as exc:
await respond(interaction, str(exc))
@group.command(name="end", description="End the current watch-party session")
async def end_command(interaction: Any) -> None:
try:
await do_end_session(interaction)
except Exception as exc:
await respond(interaction, str(exc))
tree.add_command(group)
self.client.add_view(WatchPartyPanelView())
self._commands_registered = True
def discord_request(
method: str,
token: str,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
body = None
headers = {
"Authorization": f"Bot {token}",
"User-Agent": "ArchiveStatusBot/1.0",
}
if payload is not None:
body = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
f"{DISCORD_API}{path}",
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
data = response.read()
if not data:
return {}
return json.loads(data.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
def discord_multipart_request(
method: str,
token: str,
path: str,
payload: dict[str, Any],
files: list[tuple[str, str, str, bytes]],
) -> dict[str, Any]:
boundary = f"----ArchiveBot{secrets.token_hex(16)}"
body = bytearray()
def add_part(name: str, content: bytes, content_type: str, filename: str | None = None) -> None:
body.extend(f"--{boundary}\r\n".encode("ascii"))
disposition = f'Content-Disposition: form-data; name="{name}"'
if filename is not None:
disposition += f'; filename="{filename}"'
body.extend(f"{disposition}\r\n".encode("utf-8"))
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
body.extend(content)
body.extend(b"\r\n")
add_part("payload_json", json.dumps(payload).encode("utf-8"), "application/json")
for field_name, filename, content_type, content in files:
add_part(field_name, content, content_type, filename)
body.extend(f"--{boundary}--\r\n".encode("ascii"))
request = urllib.request.Request(
f"{DISCORD_API}{path}",
data=bytes(body),
headers={
"Authorization": f"Bot {token}",
"User-Agent": "ArchiveStatusBot/1.0",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read()
if not data:
return {}
return json.loads(data.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
def discord_delete_message(token: str, channel_id: str, message_id: str) -> None:
try:
discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
except RuntimeError as exc:
print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr)
def discord_bot_identity(token: str) -> dict[str, Any]:
return discord_request("GET", token, "/users/@me")