<roblox version="4">
  <Item class="Script" referent="0">
    <Properties>
      <string name="Name">AFK AI</string>
      <token name="RunContext">0</token>
      <string name="Source"><![CDATA[--!strict
-- The AFK AI plugin for Roblox Studio: a toolbar button and an "AFK AI" panel. The person signs in with a code
-- (Studio can't open a browser or listen for one), picks a .glb/.gltf/.fbx file on their computer, and the plugin
-- sends it to AFK AI's cloud rigger, waits, and hands back the download link with the steps to bring the rigged,
-- animated model into Studio. A plugin can't save a file to disk or run Studio's 3D importer itself, so those last
-- steps are the person's (README.md says why). The protocol is in AfkClient (tested outside Studio); this file is
-- only Studio's side of it: the panel, HttpService, settings and the file picker.

local HttpService = game:GetService("HttpService")
local StudioService = game:GetService("StudioService")

local AfkClient = require(script.AfkClient)
local Config = require(script.Config)

local client = AfkClient.new({
	site = Config.site,
	api = Config.api,
	identity = Config.identity,
	securetoken = Config.securetoken,
	apiKey = Config.apiKey,
	app = Config.app,
	version = Config.version,
	-- Studio doesn't tell a plugin whether it runs on a Mac or Windows (UserInputService:GetPlatform isn't open to
	-- plugins), and the sign-in page only counts it, so this says so rather than guess.
	platform = "unknown",
	settingsPrefix = Config.settingsPrefix,
}, {
	request = function(method, url, headers, body)
		-- Errors when there's no answer at all, or when the person hasn't let the plugin reach this address (Studio
		-- asks the first time; the Plugins tab's Manage Plugins changes it later).
		local res = HttpService:RequestAsync({ Url = url, Method = method, Headers = headers, Body = body })
		local lower = {}
		for k, v in res.Headers do
			lower[string.lower(k)] = v
		end
		return { status = res.StatusCode, headers = lower, body = res.Body }
	end,
	jsonEncode = function(value)
		return HttpService:JSONEncode(value)
	end,
	jsonDecode = function(text)
		return HttpService:JSONDecode(text)
	end,
	now = os.time,
	sleep = function(seconds)
		task.wait(seconds)
	end,
	randomId = function()
		-- 32 hex characters from a random (version 4) GUID: an Idempotency-Key, or the sign-in link's state
		return (string.gsub(HttpService:GenerateGUID(false), "-", ""))
	end,
	-- the person's own Studio settings, kept by Studio outside any place file
	load = function(key)
		local ok, value = pcall(plugin.GetSetting, plugin, key)
		return if ok then value else nil
	end,
	save = function(key, value)
		pcall(plugin.SetSetting, plugin, key, value)
	end,
})

-------------------------------------------------------------------------------------------------------------------
-- The panel's pieces

local toolbar = plugin:CreateToolbar("AFK AI")
local toolbarButton = toolbar:CreateButton("afk-ai-rig", "Rig and animate a 3D model with AFK AI", "", "Rig with AFK AI")
toolbarButton.ClickableWhenViewportHidden = true

local widgetInfo = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 340, 600, 280, 360)
local widget: DockWidgetPluginGui
do
	-- the Async one is current; the other is for a Studio from before it
	local ok, made = pcall(function()
		return plugin:CreateDockWidgetPluginGuiAsync("AFK_AI_Panel", widgetInfo)
	end)
	widget = if ok then made else (plugin :: any):CreateDockWidgetPluginGui("AFK_AI_Panel", widgetInfo)
end
widget.Title = if Config.dev then "AFK AI (dev)" else "AFK AI"
widget.Name = "AFK AI"

-- Studio's own colors, light or dark, kept up when the person switches theme.
local Colors = Enum.StudioStyleGuideColor
local painted: { [Instance]: { [string]: Enum.StudioStyleGuideColor } } = {}
local function studioColor(color: Enum.StudioStyleGuideColor): Color3
	return (settings() :: any).Studio.Theme:GetColor(color)
end
local function paint(inst: Instance, prop: string, color: Enum.StudioStyleGuideColor)
	painted[inst] = painted[inst] or {}
	painted[inst][prop] = color
	;(inst :: any)[prop] = studioColor(color)
end
;(settings() :: any).Studio.ThemeChanged:Connect(function()
	for inst, props in painted do
		for prop, color in props do
			(inst :: any)[prop] = studioColor(color)
		end
	end
end)

local function new(className: string, props: { [string]: any }): any
	local inst = Instance.new(className)
	for k, v in props do
		if k ~= "Parent" then
			(inst :: any)[k] = v
		end
	end
	inst.Parent = props.Parent
	return inst
end

local order = 0
local function nextOrder(): number
	order += 1
	return order
end

local function list(parent: Instance, padding: number)
	new("UIListLayout", { Parent = parent, SortOrder = Enum.SortOrder.LayoutOrder, Padding = UDim.new(0, padding) })
end

local function section(parent: Instance): Frame
	local f = new("Frame", {
		Parent = parent,
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		LayoutOrder = nextOrder(),
	})
	list(f, 6)
	return f
end

local function label(parent: Instance, text: string, style: string?): TextLabel
	local l = new("TextLabel", {
		Parent = parent,
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		Text = text,
		TextWrapped = true,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = Enum.TextYAlignment.Top,
		Font = if style == "title" or style == "heading" then Enum.Font.SourceSansBold else Enum.Font.SourceSans,
		TextSize = if style == "title" then 20 elseif style == "small" then 14 else 16,
		LayoutOrder = nextOrder(),
	})
	paint(l, "TextColor3", if style == "small" then Colors.SubText else Colors.MainText)
	return l
end

local function rounded(inst: Instance)
	new("UICorner", { Parent = inst, CornerRadius = UDim.new(0, 4) })
end

local function button(parent: Instance, text: string, primary: boolean, onClick: () -> ()): TextButton
	local b = new("TextButton", {
		Parent = parent,
		Size = UDim2.new(1, 0, 0, 30),
		Text = text,
		Font = if primary then Enum.Font.SourceSansBold else Enum.Font.SourceSans,
		TextSize = 16,
		AutoButtonColor = true,
		LayoutOrder = nextOrder(),
	})
	rounded(b)
	paint(b, "BackgroundColor3", if primary then Colors.DialogMainButton else Colors.Button)
	paint(b, "TextColor3", if primary then Colors.DialogMainButtonText else Colors.ButtonText)
	b.Activated:Connect(function()
		if b.Active then
			onClick()
		end
	end)
	return b
end

local function setEnabled(b: TextButton, enabled: boolean)
	b.Active = enabled
	b.AutoButtonColor = enabled
	b.TextTransparency = if enabled then 0 else 0.5
end

local function input(parent: Instance, placeholder: string): TextBox
	local t = new("TextBox", {
		Parent = parent,
		Size = UDim2.new(1, 0, 0, 30),
		Text = "",
		PlaceholderText = placeholder,
		ClearTextOnFocus = false,
		ClipsDescendants = true,
		Font = Enum.Font.SourceSans,
		TextSize = 15,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = nextOrder(),
	})
	rounded(t)
	new("UIPadding", { Parent = t, PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) })
	paint(t, "BackgroundColor3", Colors.InputFieldBackground)
	paint(t, "TextColor3", Colors.MainText)
	paint(t, "PlaceholderColor3", Colors.DimmedText)
	return t
end

-- Text to copy. A plugin can't put anything on the clipboard, so this is a box whose text is all selected when it's
-- clicked, ready for Ctrl+C (Cmd+C on a Mac); typing in it puts the text back.
local copyText: { [TextBox]: string } = {}
local function copyBox(parent: Instance): TextBox
	local t = input(parent, "")
	copyText[t] = ""
	t.Focused:Connect(function()
		task.defer(function()
			t.CursorPosition = #t.Text + 1
			t.SelectionStart = 1
		end)
	end)
	t:GetPropertyChangedSignal("Text"):Connect(function()
		if t.Text ~= copyText[t] then
			t.Text = copyText[t]
		end
	end)
	return t
end
local function setCopy(t: TextBox, text: string)
	copyText[t] = text
	t.Text = text
end

type Option = { value: string, text: string }

-- One of a few choices: a button showing the current one, and the list under it when it's clicked.
local function choice(parent: Instance, title: string, options: { Option }, current: string, onPick: (string) -> ())
	local holder = section(parent)
	label(holder, title, "heading")
	local menu: Frame
	local shown = button(holder, "", false, function()
		menu.Visible = not menu.Visible
	end)
	menu = new("Frame", {
		Parent = holder,
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		Visible = false,
		LayoutOrder = nextOrder(),
	})
	list(menu, 2)
	local function rebuild(opts: { Option })
		for _, child in menu:GetChildren() do
			if child:IsA("TextButton") then
				child:Destroy()
			end
		end
		for _, o in opts do
			if o.value == current then
				shown.Text = o.text .. "  ▾"
			end
			local b = button(menu, o.text, false, function()
				current = o.value
				shown.Text = o.text .. "  ▾"
				menu.Visible = false
				onPick(current)
			end)
			b.Size = UDim2.new(1, 0, 0, 24)
		end
	end
	rebuild(options)
	return rebuild
end

-------------------------------------------------------------------------------------------------------------------
-- The panel: built top to bottom. What the buttons do is filled in further down (the handlers), once every part of
-- the panel they change exists.

local handlers: { [string]: () -> () } = {}
local function handler(name: string): () -> ()
	return function()
		handlers[name]()
	end
end

local root = new("ScrollingFrame", {
	Parent = widget,
	Size = UDim2.fromScale(1, 1),
	CanvasSize = UDim2.new(0, 0, 0, 0),
	AutomaticCanvasSize = Enum.AutomaticSize.Y,
	ScrollBarThickness = 6,
	BorderSizePixel = 0,
})
paint(root, "BackgroundColor3", Colors.MainBackground)
new("UIPadding", {
	Parent = root,
	PaddingTop = UDim.new(0, 10),
	PaddingBottom = UDim.new(0, 10),
	PaddingLeft = UDim.new(0, 10),
	PaddingRight = UDim.new(0, 14),
})
list(root, 12)

local header = section(root)
label(header, "AFK AI", "title")
label(header, "Rig and animate a 3D model in the cloud, then bring it into Studio.", "small")
if Config.dev then
	paint(label(header, "DEV BUILD: everything goes to " .. Config.api .. ", not to AFK AI.", "heading"), "TextColor3", Colors.WarningText)
end

-- Signed out: the sign-in link and the code box.
local signedOutView = section(root)
label(signedOutView, "Sign in with your afk.ai account.", "heading")
local signInButton = button(signedOutView, "Sign in", true, handler("signIn"))
local signInSteps = section(signedOutView)
label(signInSteps, "1. Copy this link and open it in your browser, then sign in with Google:", "small")
local signInLink = copyBox(signInSteps)
label(signInSteps, "2. afk.ai shows you a sign-in code. Copy it and paste it below.", "small")
label(signedOutView, "I have a sign-in code:", "small")
local codeBox = input(signedOutView, "Paste the code from afk.ai here")
local useCodeButton = button(signedOutView, "Use code", false, handler("useCode"))
local signInMessage = label(signedOutView, "", "small")
paint(signInMessage, "TextColor3", Colors.ErrorText)

-- Signed in: who, and the rigs left this month.
local signedInView = section(root)
local whoLabel = label(signedInView, "", "heading")
local rigsLabel = label(signedInView, "", "small")
button(signedInView, "Sign out", false, handler("signOut"))

-- What to rig, and how.
local state = {
	file = nil :: File?,
	bodyType = "human",
	animations = "all",
	facing = "auto",
	signingIn = false,
	rigging = false,
	stop = false,
	opened = false,
}

local rigView = section(root)
label(rigView, "Model", "heading")
local chooseButton = button(rigView, "Choose a model file...", false, handler("chooseFile"))
local fileLabel = label(rigView, "No file chosen. A .glb, .gltf (with everything embedded) or .fbx file, up to 50 MB.", "small")

local function typeOptions(names: { string }): { Option }
	local out = {}
	for _, n in names do
		table.insert(out, { value = n, text = string.upper(string.sub(n, 1, 1)) .. string.sub(n, 2) })
	end
	return out
end
local rebuildTypes = choice(rigView, "Body type", typeOptions(AfkClient.TYPES), state.bodyType, function(v)
	state.bodyType = v
end)
choice(rigView, "Animations", {
	{ value = "all", text = "All animations" },
	{ value = "none", text = "Rig only" },
}, state.animations, function(v)
	state.animations = v
end)
choice(rigView, "Facing", {
	{ value = "auto", text = "Auto" },
	{ value = "0", text = "0°" },
	{ value = "90", text = "90°" },
	{ value = "180", text = "180°" },
	{ value = "270", text = "270°" },
}, state.facing, function(v)
	state.facing = v
end)
local rigButton = button(rigView, "Rig", true, handler("rig"))

-- Progress and problems.
local statusView = section(root)
local statusLabel = label(statusView, "", nil)
local stopButton = button(statusView, "Stop waiting", false, function()
	state.stop = true
end)
local plansView = section(statusView)
label(plansView, "See plans: copy this link into your browser.", "small")
local plansLink = copyBox(plansView)
local hintLabel = label(statusView, "", "small")

-- The result, and how to bring it into Studio.
local resultView = section(root)
local resultTitle = label(resultView, "", "heading")
label(
	resultView,
	"1. Copy this link and open it in your browser to download the rigged model. Anyone who has the link can download the model, so keep it to yourself.",
	"small"
)
local resultLink = copyBox(resultView)
local resultExpiry = label(resultView, "", "small")
label(
	resultView,
	"2. In Studio's File menu, choose Import 3D and pick the downloaded file. Set Rig Type to Custom, then Import. The model arrives in the Workspace with its bones.",
	"small"
)
label(
	resultView,
	"3. For its animations: select the model, open the Animation Editor (Avatar tab), and in its ⋯ menu choose Import, then From File, and pick the same file. Its animations are saved in the place; switch between them with Load. To play one in a published game, publish it from the Animation Editor.",
	"small"
)
label(resultView, "Your original file is left as it was.", "small")

-------------------------------------------------------------------------------------------------------------------
-- Keeping the panel up to date

local function render()
	local signedIn = client:isSignedIn()
	signedOutView.Visible = not signedIn
	signedInView.Visible = signedIn
	rigView.Visible = signedIn
	whoLabel.Text = "Signed in as " .. (client:email() or "your afk.ai account")
	signInMessage.Visible = signInMessage.Text ~= ""
	setEnabled(useCodeButton, not state.signingIn)
	setEnabled(chooseButton, not state.rigging)
	setEnabled(rigButton, signedIn and state.file ~= nil and not state.rigging)
	stopButton.Visible = state.rigging
	statusView.Visible = statusLabel.Text ~= ""
	hintLabel.Visible = hintLabel.Text ~= ""
end

local function setStatus(text: string, isProblem: boolean?)
	statusLabel.Text = text
	paint(statusLabel, "TextColor3", if isProblem then Colors.ErrorText else Colors.MainText)
	plansView.Visible = false
	hintLabel.Text = ""
	render()
end

local function showProblem(p: AfkClient.Problem)
	setStatus(p.message, true)
	if p.plans then
		setCopy(plansLink, Config.site .. "/lifetime")
		plansView.Visible = true
	end
	if p.code == "offline" then
		hintLabel.Text = "If Studio asked whether AFK AI may connect, it needs Allow. You can change that in the Plugins tab: Manage Plugins, AFK AI."
	end
	render()
end

local function refreshAccount()
	if not client:isSignedIn() then
		render()
		return
	end
	rigsLabel.Text = "Checking your rigs..."
	local account, p = client:account()
	rigsLabel.Text = ""
	if account == nil then
		if p and p.signedOut then
			showProblem(p)
		end
		render()
		return
	end
	local rigs = if type(account.rigs) == "table" then account.rigs else {}
	if type(rigs.remaining) == "number" then
		local perMonth = if type(rigs.per_month) == "number" then rigs.per_month else rigs.remaining
		rigsLabel.Text = string.format("%d of %d rigs left this month.", rigs.remaining, perMonth)
	end
	render()
end

-- "2026-11-15T12:00:00Z" as the person's own local time, or as it is if Studio can't read it.
local function localTime(iso: string): string
	local ok, text = pcall(function()
		return DateTime.fromIsoDate(iso):FormatLocalTime("LLL", "en-us")
	end)
	return if ok and type(text) == "string" then text else iso
end

-------------------------------------------------------------------------------------------------------------------
-- What the buttons do

function handlers.signIn()
	setCopy(signInLink, client:signInUrl())
	signInSteps.Visible = true
	signInButton.Text = "Get a new sign-in link"
end

function handlers.useCode()
	if state.signingIn then
		return
	end
	state.signingIn = true
	signInMessage.Text = ""
	render()
	task.spawn(function()
		local email, p = client:signInWithCode(codeBox.Text)
		state.signingIn = false
		if email then
			codeBox.Text = ""
			signInSteps.Visible = false
			signInButton.Text = "Sign in"
			setStatus("")
			refreshAccount()
		else
			signInMessage.Text = (p :: AfkClient.Problem).message
			render()
		end
	end)
end

function handlers.signOut()
	client:signOut()
	setStatus("")
	resultView.Visible = false
	rigsLabel.Text = ""
	render()
end

function handlers.chooseFile()
	if state.rigging then
		return
	end
	task.spawn(function()
		local ok, file = pcall(function()
			return StudioService:PromptImportFileAsync({ "glb", "gltf", "fbx" })
		end)
		if not ok or file == nil then
			return -- canceled; Studio also gives nothing back for a file over 100 MB
		end
		local f = file :: File
		local p = AfkClient.checkModel(f.Name, f.Size)
		if p then
			state.file = nil
			fileLabel.Text = "No file chosen."
			showProblem(p)
			return
		end
		state.file = f
		fileLabel.Text = f.Name .. "  ·  " .. AfkClient.megabytes(f.Size)
		setStatus("")
	end)
end

function handlers.rig()
	local file = state.file
	if state.rigging or file == nil or not client:isSignedIn() then
		return
	end
	state.rigging = true
	state.stop = false
	resultView.Visible = false
	setStatus(AfkClient.progressText({ status = "uploading" }))
	task.spawn(function()
		task.wait() -- a frame first, so the panel shows "Sending..." before a big file is read
		local job, p = client:startRig({
			name = file.Name,
			size = file.Size,
			read = function()
				return file:GetBinaryContents()
			end,
		}, { type = state.bodyType, animations = state.animations, facing = state.facing })
		if job then
			job, p = client:waitForRig(job, function(j)
				setStatus(AfkClient.progressText(j))
			end, function()
				return state.stop
			end)
		end
		state.rigging = false
		if job == nil then
			showProblem(p :: AfkClient.Problem)
			refreshAccount()
			return
		end
		local result = if type(job.result) == "table" then job.result else {}
		local count = tonumber(result.animations) or 0
		resultTitle.Text = if count > 0
			then string.format("Rigged, with %d animation%s.", count, if count == 1 then "" else "s")
			else "Rigged."
		setCopy(resultLink, tostring(result.download_url or ""))
		resultExpiry.Text = if type(result.expires_at) == "string"
			then "The link works until " .. localTime(result.expires_at) .. "."
			else "The link works for 24 hours."
		resultView.Visible = true
		setStatus("")
		refreshAccount()
	end)
end

-------------------------------------------------------------------------------------------------------------------
-- Opening and closing

-- Nothing goes over the network until the panel is first opened, so having the plugin installed never contacts AFK
-- AI (or makes Studio ask about it) on its own.
local function opened()
	render()
	if not state.opened then
		state.opened = true
		task.spawn(function()
			local names = client:types()
			if names then
				rebuildTypes(typeOptions(names))
			end
		end)
	end
	task.spawn(refreshAccount)
end

toolbarButton.Click:Connect(function()
	widget.Enabled = not widget.Enabled
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
	toolbarButton:SetActive(widget.Enabled)
	if widget.Enabled then
		opened()
	end
end)
plugin.Unloading:Connect(function()
	state.stop = true
end)

signInSteps.Visible = false
plansView.Visible = false
resultView.Visible = false
render()
if widget.Enabled then -- Studio reopened it where the person left it
	toolbarButton:SetActive(true)
	opened()
end
]]></string>
    </Properties>
    <Item class="ModuleScript" referent="1">
      <Properties>
        <string name="Name">AfkClient</string>
        <string name="Source"><![CDATA[--!strict
-- AFK AI's protocol (plugins/PROTOCOL.md) for the Roblox Studio plugin: sign-in by code, the tokens, and the rigging
-- API. Nothing in here touches Roblox: Studio's HttpService, plugin settings, clock and waiting are handed in (Deps),
-- so the same code runs under the `luau` command line in the tests (plugins/roblox/tests), against
-- plugins/dev/fake_server.py, exactly as it runs in Studio.
--
-- The plugin decides nothing that matters: the server checks the sign-in, the plan, the limits and the file. The
-- checks here only save someone a wasted upload.

local AfkClient = {}
AfkClient.__index = AfkClient

export type Response = { status: number, headers: { [string]: string }, body: string }

export type Deps = {
	-- One HTTP request. Header names in the answer are lower case. Errors (as HttpService:RequestAsync does) when
	-- there's no answer at all: no connection, a timeout, or Studio not letting the plugin reach that address.
	request: (method: string, url: string, headers: { [string]: string }, body: string?) -> Response,
	jsonEncode: (value: any) -> string,
	jsonDecode: (text: string) -> any,
	now: () -> number, -- seconds
	sleep: (seconds: number) -> (),
	randomId: () -> string, -- 32 characters from [A-Za-z0-9_-]
	load: (key: string) -> any, -- the person's own Studio settings (plugin:GetSetting), never the place
	save: (key: string, value: any) -> (), -- nil deletes
}

export type Config = {
	site: string,
	api: string,
	identity: string,
	securetoken: string,
	apiKey: string, -- Firebase's public web API key (already in afk.ai's own pages), not a secret
	app: string,
	version: string,
	platform: string,
	settingsPrefix: string, -- a dev build keeps its tokens apart from a release build's
}

-- A problem to show the person. message is always written for people.
export type Problem = {
	code: string,
	message: string,
	status: number?,
	plans: boolean?, -- show "See plans" (Config.site .. "/lifetime")
	signedOut: boolean?, -- show Sign in again
	detail: string?, -- what Studio said when there was no answer at all (for the Output window, never a token)
}

export type ModelFile = {
	name: string,
	size: number,
	read: () -> string,
}

export type RigOptions = {
	type: string?,
	animations: string?, -- "all", "none", or names separated by commas
	facing: string?, -- "auto", "0", "90", "180", "270"
}

AfkClient.MAX_BYTES = 50 * 1024 * 1024
AfkClient.POLL_SECONDS = 3
AfkClient.RETRY_SECONDS = 3
AfkClient.REFRESH_MARGIN = 5 * 60 -- refresh an ID token with less than this left

AfkClient.OFFLINE = "Couldn't reach AFK AI. Check your connection and try again."
AfkClient.SIGNED_OUT = "You've been signed out of AFK AI. Sign in again."
AfkClient.NOT_SIGNED_IN = "Sign in to AFK AI first."
AfkClient.STOPPED = "Stopped waiting. This rig still counts toward this month's rigs."

AfkClient.TYPES = {
	"human", "fox", "horse", "bird", "dragon", "kaiju", "spider", "insect",
	"snake", "fish", "kangaroo", "frog", "turtle", "ghost", "object",
}
AfkClient.FACINGS = { "auto", "0", "90", "180", "270" }

-- Codes that come with a "See plans" link (PROTOCOL.md 2). out_of_rigs is what the cloud's /plugin/rigs answers
-- today (cloud/modal_app.py) for the same thing as no_rigs_left.
local PLAN_CODES = { no_rigs_left = true, out_of_rigs = true, rate_limited = true }

local function problem(code: string, message: string, extra: { [string]: any }?): Problem
	local p: Problem = { code = code, message = message }
	if extra then
		for k, v in extra do
			(p :: any)[k] = v
		end
	end
	return p
end

-- Percent-encodes everything but the characters URLs leave alone, for query strings and form bodies.
local function encode(s: string): string
	return (string.gsub(s, "[^%w%-%._~]", function(c: string)
		return string.format("%%%02X", string.byte(c))
	end))
end
AfkClient.encode = encode

local function trim(s: string): string
	return (string.match(s, "^%s*(.-)%s*$") :: string)
end

local function extension(name: string): string
	return string.lower(string.match(name, "%.([^%./\\]+)$") or "")
end

-- A file size in megabytes, the way people read them ("12.3 MB").
function AfkClient.megabytes(bytes: number): string
	local mb = bytes / (1024 * 1024)
	if mb >= 10 then
		return string.format("%d MB", math.floor(mb + 0.5))
	end
	return string.format("%.1f MB", mb)
end

function AfkClient.new(config: Config, deps: Deps)
	local self = setmetatable({}, AfkClient)
	self.config = config
	self.deps = deps
	self.idToken = nil :: string? -- in memory only, never stored or printed
	self.expiresAt = 0
	return self
end

export type Client = typeof(AfkClient.new(({} :: any) :: Config, ({} :: any) :: Deps))

local function key(self: Client, name: string): string
	return self.config.settingsPrefix .. name
end

function AfkClient.isSignedIn(self: Client): boolean
	return type(self.deps.load(key(self, "refresh_token"))) == "string"
end

function AfkClient.email(self: Client): string?
	local email = self.deps.load(key(self, "email"))
	return if type(email) == "string" then email else nil
end

-- Forgets the tokens and deletes the stored ones (PROTOCOL.md 1, step 8).
function AfkClient.signOut(self: Client)
	self.idToken = nil
	self.expiresAt = 0
	self.deps.save(key(self, "refresh_token"), nil)
	self.deps.save(key(self, "email"), nil)
end

-- The afk.ai page to sign in on, in code mode: Studio can't open a browser or listen on a port, so the person opens
-- this link themselves and the page shows a code to paste back (PROTOCOL.md 1, steps 2 and 4). Nothing comes back to
-- the plugin by URL, so `state` has nothing to protect here; it's sent because the page expects one.
function AfkClient.signInUrl(self: Client): string
	local c = self.config
	return string.format(
		"%s/desktop/signin?mode=code&state=%s&app=%s&version=%s&platform=%s",
		c.site,
		encode(self.deps.randomId()),
		encode(c.app),
		encode(c.version),
		encode(c.platform)
	)
end

-- One request; the response, or nil and what Studio said when there was no answer.
local function send(self: Client, method: string, url: string, headers: { [string]: string }, body: string?): (Response?, string?)
	local ok, res = pcall(self.deps.request, method, url, headers, body)
	if ok then
		return res :: Response, nil
	end
	return nil, tostring(res)
end

-- Firebase's calls: once more after 3 seconds when there's no answer or a 5xx (as the API's).
local function sendFirebase(self: Client, url: string, contentType: string, body: string): (Response?, string?)
	local res, err = send(self, "POST", url, { ["Content-Type"] = contentType }, body)
	if res == nil or res.status >= 500 then
		self.deps.sleep(AfkClient.RETRY_SECONDS)
		res, err = send(self, "POST", url, { ["Content-Type"] = contentType }, body)
	end
	return res, err
end

local function decode(self: Client, body: string): any
	local ok, value = pcall(self.deps.jsonDecode, body)
	return if ok then value else nil
end

-- Signs in with the code the afk.ai page showed (a Google ID token): exchanges it for a Firebase session and keeps
-- the refresh token in the person's own Studio settings (PROTOCOL.md 1, steps 5 and 6). Returns the email.
function AfkClient.signInWithCode(self: Client, code: string): (string?, Problem?)
	code = trim(code)
	if code == "" then
		return nil, problem("no_code", "Paste the sign-in code from the afk.ai page first.")
	end
	if string.find(code, "%s") or #code > 8192 then
		return nil, problem("bad_code", "That isn't a sign-in code. Copy the whole code from the afk.ai page.")
	end
	local c = self.config
	local body = self.deps.jsonEncode({
		postBody = "id_token=" .. encode(code) .. "&providerId=google.com",
		requestUri = "http://localhost",
		returnSecureToken = true,
	})
	local res, err = sendFirebase(self, c.identity .. "/v1/accounts:signInWithIdp?key=" .. encode(c.apiKey), "application/json", body)
	if res == nil then
		return nil, problem("offline", AfkClient.OFFLINE, { detail = err })
	end
	local data = decode(self, res.body)
	if res.status ~= 200 or type(data) ~= "table" or type(data.refreshToken) ~= "string" or type(data.idToken) ~= "string" then
		if res.status >= 500 then
			return nil, problem("offline", AfkClient.OFFLINE, { status = res.status })
		end
		return nil, problem("sign_in_failed", "That sign-in code didn't work. Codes work for an hour: sign in again for a new one.", { status = res.status })
	end
	self.idToken = data.idToken
	self.expiresAt = self.deps.now() + (tonumber(data.expiresIn) or 3600)
	local email = if type(data.email) == "string" then data.email else ""
	self.deps.save(key(self, "refresh_token"), data.refreshToken)
	self.deps.save(key(self, "email"), email)
	return email, nil
end

-- An ID token with at least 5 minutes left, refreshed first if need be (PROTOCOL.md 1, step 7). A 400 from the
-- refresh means signed out: the tokens are forgotten.
function AfkClient.token(self: Client, force: boolean?): (string?, Problem?)
	if not force and self.idToken and self.expiresAt - self.deps.now() > AfkClient.REFRESH_MARGIN then
		return self.idToken, nil
	end
	local refresh = self.deps.load(key(self, "refresh_token"))
	if type(refresh) ~= "string" then
		self.idToken = nil
		return nil, problem("signed_out", AfkClient.NOT_SIGNED_IN, { signedOut = true })
	end
	local c = self.config
	local res, err = sendFirebase(
		self,
		c.securetoken .. "/v1/token?key=" .. encode(c.apiKey),
		"application/x-www-form-urlencoded",
		"grant_type=refresh_token&refresh_token=" .. encode(refresh)
	)
	if res == nil or res.status >= 500 then
		return nil, problem("offline", AfkClient.OFFLINE, { detail = err })
	end
	local data = decode(self, res.body)
	if res.status == 400 or res.status == 401 or res.status == 403 then
		self:signOut()
		return nil, problem("signed_out", AfkClient.SIGNED_OUT, { signedOut = true, status = res.status })
	end
	if res.status ~= 200 or type(data) ~= "table" or type(data.id_token) ~= "string" then
		return nil, problem("offline", AfkClient.OFFLINE, { status = res.status })
	end
	self.idToken = data.id_token
	self.expiresAt = self.deps.now() + (tonumber(data.expires_in) or 3600)
	if type(data.refresh_token) == "string" and data.refresh_token ~= refresh then
		self.deps.save(key(self, "refresh_token"), data.refresh_token)
	end
	return self.idToken, nil
end

-- What an error answer says, as a Problem (PROTOCOL.md 2, Errors).
local function apiProblem(self: Client, res: Response): Problem
	local data = decode(self, res.body)
	local err: any = if type(data) == "table" and type(data.error) == "table" then data.error else {}
	local code = if type(err.code) == "string" and err.code ~= "" then err.code else nil
	local message = if type(err.message) == "string" and err.message ~= "" then err.message else nil
	if res.status >= 500 then
		-- the server's own words when it gave some (down for maintenance, say), else PROTOCOL.md's
		return problem(code or "offline", message or AfkClient.OFFLINE, { status = res.status })
	end
	if res.status == 402 and not code then
		code = "no_rigs_left"
	elseif res.status == 429 and not code then
		code = "rate_limited"
	end
	local plans = res.status == 402 or (res.status == 429 and PLAN_CODES[code or ""] == true)
	return problem(
		code or "error",
		message or string.format("AFK AI couldn't do that (HTTP %d).", res.status),
		{ status = res.status, plans = plans }
	)
end

export type CallOptions = {
	auth: boolean?, -- default true
	body: string?,
	headers: { [string]: string }?,
}

-- A call to the rigging API (PROTOCOL.md 2): a 401 refreshes the token and tries once more (then it's signed out);
-- a 5xx or no answer tries once more after 3 seconds with the same headers, so a rig's retry carries the same
-- Idempotency-Key and never starts a second rig. Returns the decoded JSON and the response, or nil and a Problem.
function AfkClient.call(self: Client, method: string, path: string, options: CallOptions?): (any, Response | Problem)
	local opts: CallOptions = options or {}
	local auth = opts.auth ~= false
	local refreshed = false -- after a 401: refreshed once already
	local force = false -- refresh before the next try
	local retried = false
	while true do
		local headers: { [string]: string } = {
			["X-AFK-Client"] = self.config.app .. "/" .. self.config.version,
			["Accept"] = "application/json",
		}
		local extra: { [string]: string } = opts.headers or {}
		for k, v in extra do
			headers[k] = v
		end
		if auth then
			local token, p = self:token(force)
			force = false
			if token == nil then
				return nil, p :: Problem
			end
			headers["Authorization"] = "Bearer " .. token
		end
		local res, err = send(self, method, self.config.api .. path, headers, opts.body)
		if res == nil then
			if retried then
				return nil, problem("offline", AfkClient.OFFLINE, { detail = err })
			end
			retried = true
			self.deps.sleep(AfkClient.RETRY_SECONDS)
		elseif res.status == 401 and auth then
			if refreshed then
				self:signOut()
				return nil, problem("signed_out", AfkClient.SIGNED_OUT, { signedOut = true, status = 401 })
			end
			refreshed = true
			force = true
		elseif res.status >= 500 and not retried then
			retried = true
			self.deps.sleep(AfkClient.RETRY_SECONDS)
		elseif res.status >= 400 then
			return nil, apiProblem(self, res)
		else
			local data = decode(self, res.body)
			if data == nil then
				return nil, problem("bad_answer", "AFK AI sent back something unexpected. Try again.", { status = res.status })
			end
			return data, res
		end
	end
end

-- The body types and their animations (GET /v1/types, no sign-in): a list of type names, or nil and a Problem.
function AfkClient.types(self: Client): ({ string }?, Problem?)
	local data, res = self:call("GET", "/v1/types", { auth = false })
	if data == nil then
		return nil, res :: Problem
	end
	local names = {}
	if type(data.types) == "table" then
		for _, t in data.types do
			if type(t) == "table" and type(t.type) == "string" then
				table.insert(names, t.type)
			end
		end
	end
	if #names == 0 then
		return nil, problem("bad_answer", "AFK AI sent back no body types.")
	end
	return names, nil
end

-- The signed-in account: {email, plan, rigs = {per_month, used, remaining, resets_at}, gold}.
function AfkClient.account(self: Client): (any, Problem?)
	local data, res = self:call("GET", "/plugin/account")
	if data == nil then
		return nil, res :: Problem
	end
	if type(data.email) == "string" and data.email ~= "" and data.email ~= self:email() then
		self.deps.save(key(self, "email"), data.email)
	end
	return data, nil
end

-- Whether this file can be sent, before reading or uploading any of it (the server checks again).
function AfkClient.checkModel(name: string, size: number): Problem?
	local ext = extension(name)
	if ext ~= "glb" and ext ~= "gltf" and ext ~= "fbx" then
		local what = if ext == "" then "this file" else "." .. ext .. " files"
		return problem("unsupported_format", "AFK AI rigs .glb, .gltf (with everything embedded) or .fbx files, not " .. what .. ".")
	end
	if size <= 0 then
		return problem("empty", "The model file is empty.")
	end
	if size > AfkClient.MAX_BYTES then
		return problem("too_large", "This model is " .. AfkClient.megabytes(size) .. ". AFK AI rigs models up to 50 MB.")
	end
	return nil
end

-- A .gltf has to carry its buffers and textures inside it: one that points at files next to it can't be rigged
-- from here, since only the .gltf itself is sent.
function AfkClient.checkGltf(text: string): Problem?
	for uri in string.gmatch(text, '"uri"%s*:%s*"([^"]*)"') do
		if string.sub(uri, 1, 5) ~= "data:" then
			return problem(
				"unsupported_format",
				"This .gltf uses files next to it (" .. string.sub(uri, 1, 60) .. "). Export it as .glb, or as .gltf with everything embedded."
			)
		end
	end
	return nil
end

local function oneOf(value: string, list: { string }): boolean
	return table.find(list, value) ~= nil
end

-- Sends the model and starts its rig (POST /plugin/rigs, the file as the raw body). Returns the job
-- ({id, status, ...}), or nil and a Problem.
function AfkClient.startRig(self: Client, file: ModelFile, options: RigOptions?): (any, Problem?)
	local opts: RigOptions = options or {}
	local bodyType = opts.type or "human"
	local animations = opts.animations or "all"
	local facing = opts.facing or "auto"
	if not oneOf(facing, AfkClient.FACINGS) then
		return nil, problem("bad_facing", "Facing is Auto, 0, 90, 180 or 270 degrees.")
	end
	local bad = AfkClient.checkModel(file.name, file.size)
	if bad then
		return nil, bad
	end
	if not self:isSignedIn() then
		return nil, problem("signed_out", AfkClient.NOT_SIGNED_IN, { signedOut = true })
	end
	local ok, body = pcall(file.read)
	if not ok or type(body) ~= "string" then
		return nil, problem("no_file", "Couldn't read " .. file.name .. ". Choose it again.")
	end
	-- checked again on what was actually read: the file may have changed since it was chosen
	bad = AfkClient.checkModel(file.name, #body)
	if bad then
		return nil, bad
	end
	if extension(file.name) == "gltf" then
		bad = AfkClient.checkGltf(body)
		if bad then
			return nil, bad
		end
	end
	local path = string.format(
		"/plugin/rigs?filename=%s&type=%s&animations=%s&facing=%s",
		encode(file.name),
		encode(bodyType),
		encode(animations),
		encode(facing)
	)
	local job, res = self:call("POST", path, {
		body = body,
		headers = {
			["Content-Type"] = "application/octet-stream",
			["Idempotency-Key"] = self.deps.randomId(), -- one per rig attempt, kept by call()'s retry
		},
	})
	if job == nil then
		return nil, res :: Problem
	end
	if type(job.id) ~= "string" then
		return nil, problem("bad_answer", "AFK AI sent back something unexpected. Try again.")
	end
	return job, nil
end

-- The job as it is now, and how many seconds to wait before asking again.
function AfkClient.getJob(self: Client, id: string): (any, Problem | number)
	local job, res = self:call("GET", "/plugin/rigs/" .. encode(id))
	if job == nil then
		return nil, res :: Problem
	end
	local wait = tonumber((res :: Response).headers["retry-after"] or "") or AfkClient.POLL_SECONDS
	return job, math.clamp(wait, 0.5, 30)
end

-- Asks after the job until it's done: every 3 seconds, or what Retry-After says. onProgress(job) hears each answer.
-- stop() is asked often; when it says true, this returns the "stopped" Problem (the rig still counts). Returns the
-- succeeded job, or nil and a Problem (a failed rig's carries the server's message).
function AfkClient.waitForRig(self: Client, job: any, onProgress: ((any) -> ())?, stop: (() -> boolean)?): (any, Problem?)
	local id = tostring(job.id)
	local wait = AfkClient.POLL_SECONDS
	local started = self.deps.now()
	if onProgress then
		onProgress(job)
	end
	while job.status ~= "succeeded" and job.status ~= "failed" do
		local waited = 0
		while waited < wait do
			if stop and stop() then
				return nil, problem("stopped", AfkClient.STOPPED)
			end
			local step = math.min(0.5, wait - waited)
			self.deps.sleep(step)
			waited += step
		end
		if stop and stop() then
			return nil, problem("stopped", AfkClient.STOPPED)
		end
		if self.deps.now() - started > 30 * 60 then
			return nil, problem("timeout", "This rig is taking far longer than it should. Try again later.")
		end
		local next, after = self:getJob(id)
		if next == nil then
			return nil, after :: Problem
		end
		job = next
		wait = after :: number
		if onProgress then
			onProgress(job)
		end
	end
	if job.status == "failed" then
		local err: any = if type(job.error) == "table" then job.error else {}
		return nil,
			problem(
				if type(err.code) == "string" then err.code else "rig_failed",
				if type(err.message) == "string" and err.message ~= ""
					then err.message
					else "The rig failed. Try again, or try another body type."
			)
	end
	return job, nil
end

-- A rig's progress in a few words.
function AfkClient.progressText(job: any): string
	local status = if type(job) == "table" then job.status else nil
	if status == "uploading" then
		return "Sending the model to AFK AI..."
	elseif status == "queued" then
		return "Waiting for a rigger..."
	elseif status == "rigging" then
		return "Rigging..."
	elseif status == "exporting" then
		return "Adding the animations..."
	elseif status == "succeeded" then
		return "Done."
	elseif status == "failed" then
		local err: any = if type(job.error) == "table" then job.error else {}
		return if type(err.message) == "string" then err.message else "The rig failed."
	end
	return "Working..."
end

return AfkClient
]]></string>
      </Properties>
    </Item>
    <Item class="ModuleScript" referent="2">
      <Properties>
        <string name="Name">Config</string>
        <string name="Source"><![CDATA[--!strict
-- Where the plugin talks to, and who it says it is (plugins/PROTOCOL.md, Addresses). A release build uses these as
-- they are. `python3 build.py --dev-url <its address>` makes a separate dev build that sends everything to
-- the stand-in server instead (plugins/dev/fake_server.py) and says so in its panel. The addresses are fixed when the
-- plugin is built, never read from the place: a place file someone shares must not be able to send a person's
-- sign-in somewhere else.

return {
	version = "0.1.0",
	app = "roblox",
	-- BEGIN ADDRESSES (build.py swaps this block for a dev build)
	dev = false,
	site = "https://afk.ai",
	api = "https://burtndylan--afk-cloud-rigger-api.modal.run",
	identity = "https://identitytoolkit.googleapis.com",
	securetoken = "https://securetoken.googleapis.com",
	settingsPrefix = "afk_",
	-- END ADDRESSES
	-- Firebase's public web API key, the one afk.ai's own pages carry. Not a secret.
	apiKey = "AIzaSyAGux3FD2AMEAEWCgWysOd9gEaxebfJZ_Y",
}
]]></string>
      </Properties>
    </Item>
  </Item>
</roblox>