code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Verse;
using UnityEngine;
using RimWorld;

namespace Rimworld_Animations {

    public class AnimationSettings : ModSettings {

        public static bool orgasmQuiver, rapeShiver, soundOverride = true, hearts = true, controlGenitalRotation = false, applySemenOnAnimationOrgasm = false, fastAnimForQuickie = false,
            PlayAnimForNonsexualActs = true;
        public static bool offsetTab = false, debugMode = false;
        public static float shiverIntensity = 2f;

        public static Dictionary<string, Vector2> offsets = new Dictionary<string, Vector2>();
        public static Dictionary<string, float> rotation = new Dictionary<string, float>();

        public override void ExposeData() {

            base.ExposeData();

            Scribe_Values.Look(ref debugMode, "RJWAnimations-AnimsDebugMode", false);
            Scribe_Values.Look(ref offsetTab, "RJWAnimations-EnableOffsetTab", false);
            Scribe_Values.Look(ref controlGenitalRotation, "RJWAnimations-controlGenitalRotation", false);
            Scribe_Values.Look(ref orgasmQuiver, "RJWAnimations-orgasmQuiver");
            Scribe_Values.Look(ref fastAnimForQuickie, "RJWAnimations-fastAnimForQuickie");
            Scribe_Values.Look(ref rapeShiver, "RJWAnimations-rapeShiver");
            Scribe_Values.Look(ref hearts, "RJWAnimation-sheartsOnLovin");
            Scribe_Values.Look(ref PlayAnimForNonsexualActs, "RJWAnims-PlayAnimForNonsexualActs");
            Scribe_Values.Look(ref applySemenOnAnimationOrgasm, "RJWAnimations-applySemenOnOrgasm", false);
            Scribe_Values.Look(ref soundOverride, "RJWAnimations-rjwAnimSoundOverride", true);
            Scribe_Values.Look(ref shiverIntensity, "RJWAnimations-shiverIntensity", 2f);
            //todo: save offsetsByDefName

            Scribe_Collections.Look(ref offsets, "RJWAnimations-animationOffsets");
            Scribe_Collections.Look(ref rotation, "RJWAnimations-rotationOffsets");

            //needs to be rewritten
            //probably somewhere in options?

        }

    }

    public class RJW_Animations : Mod {

        public RJW_Animations(ModContentPack content) : base(content) {
            GetSettings<AnimationSettings>();

        }

        public override void DoSettingsWindowContents(Rect inRect) {

            Listing_Standard listingStandard = new Listing_Standard();
            listingStandard.Begin(inRect);

            listingStandard.CheckboxLabeled("Enable Sound Override", ref AnimationSettings.soundOverride);
            listingStandard.CheckboxLabeled("Control Genital Rotation", ref AnimationSettings.controlGenitalRotation);
            listingStandard.CheckboxLabeled("Play Fast Animation for Quickie", ref AnimationSettings.fastAnimForQuickie);
            listingStandard.CheckboxLabeled("Apply Semen on Animation Orgasm", ref AnimationSettings.applySemenOnAnimationOrgasm);

            if(AnimationSettings.applySemenOnAnimationOrgasm) {
                listingStandard.Label("Recommended--turn down \"Cum on body percent\" in RJW settings to about 33%");
            }

            listingStandard.CheckboxLabeled("Enable Orgasm Quiver", ref AnimationSettings.orgasmQuiver);
            listingStandard.CheckboxLabeled("Enable Rape Shiver", ref AnimationSettings.rapeShiver);
            listingStandard.CheckboxLabeled("Enable hearts during lovin'", ref AnimationSettings.hearts);
            listingStandard.CheckboxLabeled("Play animation for nonsexual acts (handholding, makeout)", ref AnimationSettings.PlayAnimForNonsexualActs);
            listingStandard.CheckboxLabeled("Enable Animation Manager Tab", ref AnimationSettings.offsetTab);

            listingStandard.Label("Shiver/Quiver Intensity (default 2): " + AnimationSettings.shiverIntensity);
            AnimationSettings.shiverIntensity = listingStandard.Slider(AnimationSettings.shiverIntensity, 0.0f, 12f);

            listingStandard.CheckboxLabeled("Debug Mode", ref AnimationSettings.debugMode);

            listingStandard.End();
            base.DoSettingsWindowContents(inRect);
        }

        public override void WriteSettings() {
            base.WriteSettings();
            OffsetMainButtonDefOf.OffsetManager.buttonVisible = AnimationSettings.offsetTab;

        }

        public override string SettingsCategory() {
            return "RJW Animation Settings";
        }
    }
}


code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using RimWorld;
using rjw.Modules.Interactions.Helpers;
using rjw.Modules.Interactions.Objects;
using UnityEngine;
using Verse;
using Verse.AI;
using rjw.Modules.Interactions.Enums;

namespace Rimworld_Animations {
    public static class AnimationUtility {
        /*
         Note: always make the list in this order:
             Female pawns, animal female pawns, male pawns, animal male pawns
        */
        public static AnimationDef tryFindAnimation(ref List<Pawn> participants, rjw.xxx.rjwSextype sexType = 0, rjw.SexProps sexProps = null) {

            InteractionWithExtension interaction = InteractionHelper.GetWithExtension(sexProps.dictionaryKey);

            if(interaction.HasInteractionTag(InteractionTag.Reverse))
            {
                Pawn buffer = participants[1];
                participants[1] = participants[0];
                participants[0] = buffer;
            }

            participants =
                participants.OrderBy(p => p.jobs.curDriver is rjw.JobDriver_SexBaseInitiator)
                .OrderBy(p => rjw.xxx.can_fuck(p))
                .ToList();

            List<Pawn> localParticipants = new List<Pawn>(participants);

            IEnumerable<AnimationDef> options = DefDatabase<AnimationDef>.AllDefs.Where((AnimationDef x) => {

                if (x.actors.Count != localParticipants.Count) {
                    return false;
                }
                for (int i = 0; i < x.actors.Count; i++) {

                    if (rjw.RJWPreferenceSettings.Malesex == rjw.RJWPreferenceSettings.AllowedSex.Nohomo) {
                        if (rjw.xxx.is_male(localParticipants[i]) && x.actors[i].isFucked) {
                            return false;
                        }
                }
				if (x.actors[i].requiredGender != null && !x.actors[i].requiredGender.Contains(localParticipants[i].gender.ToStringSafe<Gender>()))
				{
					if (AnimationSettings.debugMode)
					{
						Log.Message(string.Concat(new string[]
						{
							x.defName.ToStringSafe<string>(),
							" not selected -- ",
							localParticipants[i].def.defName.ToStringSafe<string>(),
							" ",
							localParticipants[i].Name.ToStringSafe<Name>(),
							" does not match required gender"
						}));
					}
					return false;
				}
                    if ((x.actors[i].blacklistedRaces != null) && x.actors[i].blacklistedRaces.Contains(localParticipants[i].def.defName)) {
                        if(AnimationSettings.debugMode)
                            Log.Message(x.defName.ToStringSafe() + " not selected -- " + localParticipants[i].def.defName.ToStringSafe() + " " + localParticipants[i].Name.ToStringSafe() + " is blacklisted");
                        return false;
                    }

                    if(x.actors[i].defNames.Contains("Human")) {
                        if (!rjw.xxx.is_human(localParticipants[i])) {
                            if (AnimationSettings.debugMode)
                                Log.Message(x.defName.ToStringSafe() + " not selected -- " + localParticipants[i].def.defName.ToStringSafe() + " " + localParticipants[i].Name.ToStringSafe() + " is not human");

                            return false;
                        }

                    }
                    else if (!x.actors[i].bodyDefTypes.Contains(localParticipants[i].RaceProps.body)) {

                        if (!x.actors[i].defNames.Contains(localParticipants[i].def.defName)) {

                            if (rjw.RJWSettings.DevMode) {
                                string animInfo = x.defName.ToStringSafe() + " not selected -- " + localParticipants[i].def.defName.ToStringSafe() + " " + localParticipants[i].Name.ToStringSafe() + " is not ";
                                foreach(String defname in x.actors[i].defNames) {
                                    animInfo += defname + ", ";
                                }
                                if (AnimationSettings.debugMode)
                                    Log.Message(animInfo);
                            }

                            return false;
                        }
                    }
                    //genitals checking

                    if(!GenitalCheckForPawn(x.actors[i].requiredGenitals, localParticipants[i], out string failReason)) {
                        Debug.Log("Didn't select " + x.defName + ", " + localParticipants[i].Name + " " + failReason);
                        return false;
                    }

                    //TESTING ANIMATIONS ONLY REMEMBER TO COMMENT OUT BEFORE PUSH
                    /*
                    if (x.defName != "Cunnilingus")
                        return false;
                    */

                    if (x.actors[i].isFucking && !rjw.xxx.can_fuck(localParticipants[i])) {
                        if (AnimationSettings.debugMode)
                            Log.Message(x.defName.ToStringSafe() + " not selected -- " + localParticipants[i].def.defName.ToStringSafe() + " " + localParticipants[i].Name.ToStringSafe() + " can't fuck");
                        return false;
                    }

                    if (x.actors[i].isFucked && !rjw.xxx.can_be_fucked(localParticipants[i])) {
                        if (AnimationSettings.debugMode)
                            Log.Message(x.defName.ToStringSafe() + " not selected -- " + localParticipants[i].def.defName.ToStringSafe() + " " + localParticipants[i].Name.ToStringSafe() + " can't be fucked");
                        return false;
                    }
                }
                return true;
            });
            List<AnimationDef> optionsWithInteractionType = options.ToList().FindAll(x => x.interactionDefTypes != null && x.interactionDefTypes.Contains(sexProps.sexType.ToStringSafe()));
            if (optionsWithInteractionType.Any()) {
                if (AnimationSettings.debugMode)
                    Log.Message("Selecting animation for interaction type " + sexProps.sexType.ToStringSafe() + "...");
                return optionsWithInteractionType.RandomElement();
            }
            List<AnimationDef> optionsWithSexType = options.ToList().FindAll(x => x.sexTypes != null && x.sexTypes.Contains(sexType));
            if (optionsWithSexType.Any()) {
                if (AnimationSettings.debugMode)
                    Log.Message("Selecting animation for rjwSexType " + sexType.ToStringSafe() + "...");
                return optionsWithSexType.RandomElement();
            }

            /*
            if(optionsWithInitiator.Any()) {
                if (AnimationSettings.debugMode)
                    Log.Message("Selecting animation for initiators...");
            }
            */

            if (options != null && options.Any()) {
                if (AnimationSettings.debugMode)
                    Log.Message("Randomly selecting animation...");
                return options.RandomElement();
            }
            else
                return null;
        }

        public static void RenderPawnHeadMeshInAnimation1(Mesh mesh, Vector3 loc, Quaternion quaternion, Material material, bool drawNow, Pawn pawn) {

            if (pawn == null || pawn.Map != Find.CurrentMap) {
                GenDraw.DrawMeshNowOrLater(mesh, loc, quaternion, material, drawNow);
                return;
            }

            CompBodyAnimator pawnAnimator = pawn.TryGetComp<CompBodyAnimator>();

            if (pawnAnimator == null || !pawnAnimator.isAnimating) {
                GenDraw.DrawMeshNowOrLater(mesh, loc, quaternion, material, drawNow);
            } else {
                Vector3 pawnHeadPosition = pawnAnimator.getPawnHeadPosition();
                pawnHeadPosition.y = loc.y;
                GenDraw.DrawMeshNowOrLater(MeshPool.humanlikeHeadSet.MeshAt(pawnAnimator.headFacing), pawnHeadPosition, Quaternion.AngleAxis(pawnAnimator.headAngle, Vector3.up), material, true);
            }
        }

        public static void AdjustHead(ref Quaternion quat, ref Rot4 bodyFacing, ref Vector3 pos, ref float angle, Pawn pawn, PawnRenderFlags flags)
        {
            if (flags.FlagSet(PawnRenderFlags.Portrait)) return;

            CompBodyAnimator anim = pawn.TryGetComp<CompBodyAnimator>();
            if (anim.isAnimating)
            {
                bodyFacing = anim.headFacing;
                angle = anim.headAngle;
                quat = Quaternion.AngleAxis(anim.headAngle, Vector3.up);
                pos = anim.getPawnHeadOffset();

            }
        }

        public static void RenderPawnHeadMeshInAnimation(Mesh mesh, Vector3 loc, Quaternion quaternion, Material material, bool portrait, Pawn pawn, float bodySizeFactor = 1) {

            if (pawn == null) {
                GenDraw.DrawMeshNowOrLater(mesh, loc, quaternion, material, portrait);
                return;
            }

            CompBodyAnimator pawnAnimator = pawn.TryGetComp<CompBodyAnimator>();

            if (pawnAnimator == null || !pawnAnimator.isAnimating || portrait) {
                GenDraw.DrawMeshNowOrLater(mesh, loc, quaternion, material, portrait);
            }
            else {
                Vector3 pawnHeadPosition = pawnAnimator.getPawnHeadPosition();
                pawnHeadPosition.x *= bodySizeFactor;
                pawnHeadPosition.x *= bodySizeFactor;
                pawnHeadPosition.y = loc.y;
                GenDraw.DrawMeshNowOrLater(mesh, pawnHeadPosition, Quaternion.AngleAxis(pawnAnimator.headAngle, Vector3.up), material, portrait);
            }
        }

        public static bool GenitalCheckForPawn(List<string> requiredGenitals, Pawn pawn, out string failReason) {

            failReason = null;
            if (requiredGenitals != null) {
                if (requiredGenitals.Contains("Vagina")) {

                    if (!rjw.Genital_Helper.has_vagina(pawn)) {
                        failReason = "missing vagina";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("Penis")) {

                    if (!(rjw.Genital_Helper.has_multipenis(pawn) || rjw.Genital_Helper.has_penis_infertile(pawn) || rjw.Genital_Helper.has_penis_fertile(pawn) || rjw.Genital_Helper.has_ovipositorM(pawn) || rjw.Genital_Helper.has_ovipositorF(pawn))) {
                        failReason = "missing penis";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("Mouth")) {

                    if (!rjw.Genital_Helper.has_mouth(pawn)) {
                        failReason = "missing mouth";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("Anus")) {

                    if (!rjw.Genital_Helper.has_anus(pawn)) {
                        failReason = "missing anus";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("Breasts")) {
                    if (!rjw.Genital_Helper.can_do_breastjob(pawn)) {
                        failReason = "missing breasts";
                        return false;
                    }
                }

                if (requiredGenitals.Contains("NoVagina")) {

                    if (rjw.Genital_Helper.has_vagina(pawn)) {
                        failReason = "has vagina";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("NoPenis")) {

                    if ((rjw.Genital_Helper.has_multipenis(pawn) || rjw.Genital_Helper.has_penis_infertile(pawn) || rjw.Genital_Helper.has_penis_fertile(pawn))) {
                        failReason = "has penis";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("NoMouth")) {

                    if (rjw.Genital_Helper.has_mouth(pawn)) {
                        failReason = "has mouth";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("NoAnus")) {

                    if (rjw.Genital_Helper.has_anus(pawn)) {
                        failReason = "has anus";
                        return false;
                    }

                }

                if (requiredGenitals.Contains("NoBreasts")) {
                    if (rjw.Genital_Helper.can_do_breastjob(pawn)) {
                        failReason = "has breasts";
                        return false;
                    }
                }
            }

            return true;

        }

        public static Rot4 PawnHeadRotInAnimation(Pawn pawn, Rot4 regularPos)
        {
            Debug.Log("Test");

            if(pawn?.TryGetComp<CompBodyAnimator>() != null && pawn.TryGetComp<CompBodyAnimator>().isAnimating)
            {
                return pawn.TryGetComp<CompBodyAnimator>().headFacing;
            }

            return regularPos;
        }
    }
}

license: unknown


code:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml;
using Verse;

namespace Rimworld_Animations
{
    public class PatchOperationAddOrReplace : PatchOperationPathed
    {

        protected string key;
        private XmlContainer value;

        protected override bool ApplyWorker(XmlDocument xml)
        {
            XmlNode valNode = value.node;
            bool result = false;
            IEnumerator enumerator = xml.SelectNodes(xpath).GetEnumerator();
            try
            {
                while (enumerator.MoveNext())
                {
                    object obj = enumerator.Current;
                    result = true;
                    XmlNode parentNode = obj as XmlNode;
                    XmlNode xmlNode = parentNode.SelectSingleNode(key);
                    if (xmlNode == null)
                    {
                        // Add - Add node if not existing
                        xmlNode = parentNode.OwnerDocument.CreateElement(key);
                        parentNode.AppendChild(xmlNode);
                    }
                    else
                    {
                        // Replace - Clear existing children
                        xmlNode.RemoveAll();
                    }
                    // (Re)add value
                    xmlNode.AppendChild(parentNode.OwnerDocument.ImportNode(valNode.FirstChild, true));
                }
            }
            finally
            {
                IDisposable disposable = enumerator as IDisposable;
                if (disposable != null)
                {
                    disposable.Dispose();
                }
            }
            return result;
        }

    }

}


code:
<?xml version="1.0" encoding="utf-8"?>

<ModMetaData>
	<name>Rimworld-Animations</name>
	<author>C0ffee</author>
  <url>https://gitgud.io/c0ffeeeeeeee/rimworld-animations</url>
	<supportedVersions>

1.1

1.2

1.3

	</supportedVersions>
	<packageId>c0ffee.rimworld.animations</packageId>
	<modDependencies>

			<packageId>brrainz.harmony</packageId>
			<displayName>Harmony</displayName>
			<steamWorkshopUrl>steam://url/CommunityFilePage/2009463077</steamWorkshopUrl>
			<downloadUrl>https://github.com/pardeike/HarmonyRimWorld/releases/latest</downloadUrl>

			<packageId>UnlimitedHugs.HugsLib</packageId>
			<displayName>HugsLib</displayName>
			<downloadUrl>https://github.com/UnlimitedHugs/RimworldHugsLib/releases/latest</downloadUrl>
			<steamWorkshopUrl>steam://url/CommunityFilePage/818773962</steamWorkshopUrl>

			<packageId>rim.job.world</packageId>
			<displayName>RimJobWorld</displayName>
			<downloadUrl>https://www.loverslab.com/topic/110270-mod-rimjobworld/</downloadUrl>

			<packageId>erdelf.HumanoidAlienRaces</packageId>
			<displayName>Humanoid Alien Races 2.0</displayName>
			<steamWorkshopUrl>https://steamcommunity.com/sharedfiles/filedetails/?id=839005762</steamWorkshopUrl>

	</modDependencies>
	<loadAfter>

UnlimitedHugs.HugsLib

brrainz.harmony

erdelf.humanoidalienraces

nals.facialanimation

com.yayo.yayoAni

	</loadAfter>
	<loadBefore>
	</loadBefore>
	<incompatibleWith>
	</incompatibleWith>
	<description>
    Rimworld Animations! Hurray!

    Questions or bugs?
    Chat with me on the forums: https://www.loverslab.com/topic/140386-rjw-animations/
    Or on the rjw discord: https://discord.gg/CXwHhv8

  </description>
</ModMetaData>


code:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Manifest>
  <identifier>Rimworld-Animations</identifier>
  <version>1.2.8</version>
</Manifest>


code:
<?xml version="1.0" encoding="utf-8"?>
<LanguageData>

  <OffsetManager.label>gerenciador de posições</OffsetManager.label>
  <OffsetManager.description>Controla as posições sexuais dos colonos.</OffsetManager.description>

</LanguageData>


code:
<?xml version="1.0" encoding="utf-8"?>
<LanguageData>

  <Dog_Doggystyle.label>estilo cachorrinho</Dog_Doggystyle.label>
  <Horse_Cowgirl.label>vaqueira</Horse_Cowgirl.label>

</LanguageData>


code:
<?xml version="1.0" encoding="utf-8"?>
<LanguageData>

  <Tribadism.label>tribadismo</Tribadism.label>
  <Cunnilingus.label>cunilíngua</Cunnilingus.label>

</LanguageData>


code:
<?xml version="1.0" encoding="utf-8"?>
<LanguageData>

  <Double_Penetration.label>dupla penetração</Double_Penetration.label>

</LanguageData>


code:
<?xml version="1.0" encoding="utf-8"?>
<LanguageData>

  <Doggystyle.label>estilo cachorrinho</Doggystyle.label>
  <Blowjob.label>boquete</Blowjob.label>
  <ReverseStandAndCarry.label>69</ReverseStandAndCarry.label>
  <Cowgirl.label>vaqueira</Cowgirl.label>

</LanguageData>


code:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<loadFolders>
	<v1.1>

/

1.1

	</v1.1>
	<v1.2>

/

1.2

Patch_HatsDisplaySelection/1.2

	</v1.2>
	<v1.3>

/

1.3

Patch_HatsDisplaySelection/1.2

Patch_SexToysMasturbation

Patch_SexToysMasturbation/1.3

	</v1.3>
	<v1.4>

/

1.4

Patch_HatsDisplaySelection/1.2

Patch_SexToysMasturbation

Patch_SexToysMasturbation/1.4

	</v1.4>

</loadFolders>


code:
<?xml version="1.0" encoding="utf-8" ?>
<Defs>
	<Patch_SexToysMasturbation.SexToyAnimationDef>
		<defName>Masturbation_DildoVaginal</defName>
		dildo masturbation
		<requiredBodyPart>vagina</requiredBodyPart>
		<sounds>true</sounds>
		<sexTypes>

Masturbation

		</sexTypes>
		<actors>

				<defNames>

Human

				</defNames>
				<isFucked>true</isFucked>

		</actors>

		<animationStages>

				<stageName>Masturbating</stageName>
				<isLooping>true</isLooping>
				<playTimeTicks>917</playTimeTicks>
				<stageIndex>0</stageIndex>
				<animationClips>

						<layer>LayingPawn</layer>
						<keyframes>

								<tickDuration>40</tickDuration>
								<bodyAngle>73.01611</bodyAngle>
								<headAngle>40.0739746</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.054543376</bodyOffsetZ>
								<bodyOffsetX>0.112624526</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<soundEffect>Slimy</soundEffect>
								<tickDuration>30</tickDuration>
								<bodyAngle>76.4867554</bodyAngle>
								<headAngle>45.3887634</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.0506898165</bodyOffsetZ>
								<bodyOffsetX>0.08564949</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>30</tickDuration>
								<bodyAngle>78.22131</bodyAngle>
								<headAngle>48.0072327</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.039129138</bodyOffsetZ>
								<bodyOffsetX>0.07794231</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>30</tickDuration>
								<bodyAngle>76.4867554</bodyAngle>
								<headAngle>45.3887634</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.0506898165</bodyOffsetZ>
								<bodyOffsetX>0.08564949</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>1</tickDuration>
								<bodyAngle>73.01611</bodyAngle>
								<headAngle>40.0739746</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.054543376</bodyOffsetZ>
								<bodyOffsetX>0.112624526</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

						</keyframes>

						<keyframes>

								<tickDuration>40</tickDuration>
								<positionX>-0.359264076</positionX>
								<positionZ>-0.00901746</positionZ>
								<rotation>114.011215</rotation>

								<tickDuration>30</tickDuration>
								<positionX>-0.2783391</positionX>
								<positionZ>-0.0514066666</positionZ>
								<rotation>81.16443</rotation>

								<tickDuration>30</tickDuration>
								<positionX>-0.1704393</positionX>
								<positionZ>-0.0668209046</positionZ>
								<rotation>72.8611145</rotation>

								<tickDuration>30</tickDuration>
								<positionX>-0.2783391</positionX>
								<positionZ>-0.0514066666</positionZ>
								<rotation>81.16443</rotation>

								<tickDuration>1</tickDuration>
								<positionX>-0.359264076</positionX>
								<positionZ>-0.00901746</positionZ>
								<rotation>114.011215</rotation>

						</keyframes>

				</animationClips>

				<stageName>GettingIntoPosition</stageName>
				<isLooping>false</isLooping>
				<stageIndex>0</stageIndex>
				<animationClips>

						<layer>LayingPawn</layer>
						<keyframes>

								<tickDuration>50</tickDuration>
								<bodyAngle>73.01611</bodyAngle>
								<headAngle>40.0739746</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.054543376</bodyOffsetZ>
								<bodyOffsetX>0.112624526</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<soundEffect>Slimy</soundEffect>
								<tickDuration>1</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

						</keyframes>

						<keyframes>

								<tickDuration>50</tickDuration>
								<positionX>-0.359264076</positionX>
								<positionZ>-0.00901746</positionZ>
								<rotation>114.011215</rotation>

								<tickDuration>1</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

						</keyframes>

				</animationClips>

				<stageName>FastMasturbation</stageName>
				<isLooping>true</isLooping>
				<stageIndex>0</stageIndex>
				<playTimeTicks>1610</playTimeTicks>
				<animationClips>

						<layer>LayingPawn</layer>
						<keyframes>

								<tickDuration>20</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<soundEffect>Slimy</soundEffect>
								<tickDuration>25</tickDuration>
								<bodyAngle>85.17255</bodyAngle>
								<headAngle>58.0615845</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03527552</bodyOffsetZ>
								<bodyOffsetX>0.0471138731</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>1</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

						</keyframes>

						<keyframes>

								<tickDuration>25</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

								<tickDuration>20</tickDuration>
								<positionX>-0.178146541</positionX>
								<positionZ>-0.01672452</positionZ>
								<rotation>96.95889</rotation>

								<tickDuration>1</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

						</keyframes>

				</animationClips>

				<stageName>VeryFastMasturbation</stageName>
				<isLooping>true</isLooping>
				<stageIndex>0</stageIndex>
				<playTimeTicks>225</playTimeTicks>
				<animationClips>

						<layer>LayingPawn</layer>
						<keyframes>

								<tickDuration>6</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<soundEffect>Slimy</soundEffect>
								<tickDuration>8</tickDuration>
								<bodyAngle>85.17255</bodyAngle>
								<headAngle>58.0615845</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03527552</bodyOffsetZ>
								<bodyOffsetX>0.0471138731</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>1</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

						</keyframes>

						<keyframes>

								<tickDuration>6</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

								<tickDuration>8</tickDuration>
								<positionX>-0.178146541</positionX>
								<positionZ>-0.01672452</positionZ>
								<rotation>96.95889</rotation>

								<tickDuration>1</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

						</keyframes>

				</animationClips>

				<stageName>Orgasm</stageName>
				<isLooping>false</isLooping>
				<stageIndex>0</stageIndex>
				<animationClips>

						<layer>LayingPawn</layer>
						<keyframes>

								<soundEffect>Slimy</soundEffect>
								<tickDuration>6</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<quiver>True</quiver>
								<soundEffect>Cum</soundEffect>
								<tickDuration>80</tickDuration>
								<bodyAngle>85.17255</bodyAngle>
								<headAngle>58.0615845</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03527552</bodyOffsetZ>
								<bodyOffsetX>0.0471138731</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<quiver>True</quiver>
								<soundEffect>Cum</soundEffect>
								<tickDuration>90</tickDuration>
								<bodyAngle>92.15109</bodyAngle>
								<headAngle>96.34238</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.0237147212</bodyOffsetZ>
								<bodyOffsetX>0.0432603136</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<quiver>True</quiver>
								<soundEffect>Cum</soundEffect>
								<tickDuration>70</tickDuration>
								<bodyAngle>92.15109</bodyAngle>
								<headAngle>96.34238</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.0237147212</bodyOffsetZ>
								<bodyOffsetX>0.0432603136</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>70</tickDuration>
								<bodyAngle>92.15109</bodyAngle>
								<headAngle>96.34238</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.0237147212</bodyOffsetZ>
								<bodyOffsetX>0.0432603136</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

								<tickDuration>1</tickDuration>
								<bodyAngle>81.65927</bodyAngle>
								<headAngle>58.8843079</headAngle>
								<genitalAngle>0</genitalAngle>
								<bodyOffsetZ>0.03912908</bodyOffsetZ>
								<bodyOffsetX>0.08950315</bodyOffsetX>
								<headBob>0</headBob>
								<bodyFacing>3</bodyFacing>
								<headFacing>3</headFacing>

						</keyframes>

						<keyframes>

								<tickDuration>6</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

								<tickDuration>80</tickDuration>
								<positionX>-0.178146541</positionX>
								<positionZ>-0.01672452</positionZ>
								<rotation>96.95889</rotation>

								<tickDuration>90</tickDuration>
								<positionX>-0.178146541</positionX>
								<positionZ>-0.01672452</positionZ>
								<rotation>96.95889</rotation>

								<tickDuration>70</tickDuration>
								<positionX>-0.178146541</positionX>
								<positionZ>-0.01672452</positionZ>
								<rotation>96.95889</rotation>

								<tickDuration>70</tickDuration>
								<positionX>-0.178146541</positionX>
								<positionZ>-0.01672452</positionZ>
								<rotation>96.95889</rotation>

								<tickDuration>1</tickDuration>
								<positionX>-0.2899</positionX>
								<positionZ>-0.0282852575</positionZ>
								<rotation>98.13748</rotation>

						</keyframes>

				</animationClips>

		</animationStages>
	</Patch_SexToysMasturbation.SexToyAnimationDef>
</Defs>


code:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
  <PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <ProjectGuid>{87763712-0536-4D5F-9EAA-520F15D4F84E}</ProjectGuid>
    <OutputType>Library</OutputType>
    <AppDesignerFolder>Properties</AppDesignerFolder>
    <RootNamespace>Patch_SexToysMasturbation</RootNamespace>
    <AssemblyName>Patch_SexToysMasturbation</AssemblyName>
    <TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
    <FileAlignment>512</FileAlignment>
    <Deterministic>true</Deterministic>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DebugSymbols>false</DebugSymbols>
    <DebugType>none</DebugType>
    <Optimize>false</Optimize>
    <OutputPath>1.4\Assemblies\</OutputPath>
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
    <GenerateSerializationAssemblies>Auto</GenerateSerializationAssemblies>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <DebugType>pdbonly</DebugType>
    <Optimize>true</Optimize>
    <OutputPath>bin\Release\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="0Harmony">
      <HintPath>..\..\..\..\..\workshop\content\294100\1127530465\1.3\Assemblies\0Harmony.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="Assembly-CSharp">
      <HintPath>..\..\..\RimWorldWin64_Data\Managed\Assembly-CSharp.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="Rimworld-Animations, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL">
      <SpecificVersion>False</SpecificVersion>
      <HintPath>..\1.3\Assemblies\Rimworld-Animations.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="RJW">
      <HintPath>..\..\rjw\1.4\Assemblies\RJW.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="RJW-ToysAndMasturbation">
      <HintPath>..\..\rjw-toys-and-masturbation\Assemblies\RJW-ToysAndMasturbation.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="System" />
    <Reference Include="System.Core" />
    <Reference Include="System.Xml.Linq" />
    <Reference Include="System.Data.DataSetExtensions" />
    <Reference Include="Microsoft.CSharp" />
    <Reference Include="System.Data" />
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Xml" />
    <Reference Include="UnityEngine">
      <HintPath>..\..\..\RimWorldWin64_Data\Managed\UnityEngine.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="UnityEngine.CoreModule">
      <HintPath>..\..\..\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
      <Private>False</Private>
    </Reference>
  </ItemGroup>
  <ItemGroup>
    <Compile Include="Properties\AssemblyInfo.cs" />
    <Compile Include="Source\Defs\SexToyAnimationDef.cs" />
    <Compile Include="Source\Harmony\Harmony_PatchAll.cs" />
    <Compile Include="Source\Patches\HarmonyPatch_JobDriver_SexBaseInitiator.cs" />
    <Compile Include="Source\Utilities\AnimSexToyUtility.cs" />
  </ItemGroup>
  <ItemGroup>
    <Folder Include="1.3\Assemblies\" />
  </ItemGroup>
  <ItemGroup>
    <Content Include="1.3\Defs\AnimationDefs\Animations_Dildo.xml" />
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>


code:
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Patch_SexToysMasturbation")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Patch_SexToysMasturbation")]
[assembly: AssemblyCopyright("Copyright ©  2022")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]

// Setting ComVisible to false makes the types in this assembly not visible
// to COM components.  If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("87763712-0536-4d5f-9eaa-520f15d4f84e")]

// Version information for an assembly consists of the following four values:
//
//      Major Version
//      Minor Version
//      Build Number
//      Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]


code:
using Rimworld_Animations;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Patch_SexToysMasturbation
{
    public class SexToyAnimationDef : AnimationDef
    {

        public String requiredBodyPart = null;

    }
}


code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Verse;
using HarmonyLib;
using System.Reflection;

namespace Patch_SexToysMasturbation
{

    [StaticConstructorOnStartup]
    public static class Harmony_PatchAll {

        static Harmony_PatchAll() {

            Harmony val = new Harmony("animtoyspatch");
            val.PatchAll(Assembly.GetExecutingAssembly());

        }
    }
}


code:
using HarmonyLib;
using Rimworld_Animations;
using rjw;
using RJW_ToysAndMasturbation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Verse;

namespace Patch_SexToysMasturbation
{
    [HarmonyPatch(typeof(JobDriver_SexBaseInitiator), "Start")]
    public class HarmonyPatch_JobDriver_SexBaseInitiator
    {

        public static void Postfix(ref JobDriver_SexBaseInitiator __instance)
        {

            if(__instance is JobDriver_MasturbateWithToy masturbateJobDriver)
            {

                Log.Message("Rerolling animations...");
                Pawn pawn = masturbateJobDriver.pawn;
                Thing sexToy = masturbateJobDriver.dildo;

                RerollAnimationsForSexToy(pawn, sexToy, masturbateJobDriver.Bed);
            }

        }

        public static void RerollAnimationsForSexToy(Pawn pawn, Thing thing, Thing bed)
        {
            CompSexToy sextoy = thing.TryGetComp<CompSexToy>();

            SexToyAnimationDef anim = AnimSexToyUtility.tryFindAnimation(sextoy, pawn);

            if (anim != null)
            {
                Log.Message("Playing anim " + anim.defName);

                if(bed != null)
                {
                    pawn.TryGetComp<CompBodyAnimator>().setAnchor(bed);
                    thing.TryGetComp<CompThingAnimator>().setAnchor(bed);
                }
                else
                {
                    pawn.TryGetComp<CompBodyAnimator>().setAnchor(pawn.Position);
                    thing.TryGetComp<CompThingAnimator>().setAnchor(pawn.Position);
                }

                bool mirror = GenTicks.TicksGame % 2 == 0;

                pawn.TryGetComp<CompBodyAnimator>().StartAnimation(anim, new List<Pawn> { pawn }, 0, mirror);
                thing.TryGetComp<CompThingAnimator>().StartAnimation(anim, pawn, mirror);

                (pawn.jobs.curDriver as JobDriver_Sex).ticks_left = anim.animationTimeTicks;
                (pawn.jobs.curDriver as JobDriver_Sex).sex_ticks = anim.animationTimeTicks;
                (pawn.jobs.curDriver as JobDriver_Sex).duration = anim.animationTimeTicks;
            }
            else
            {
                Log.Message("No animation found");
            }

        }

    }

}


code:
using rjw;
using RJW_ToysAndMasturbation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Verse;

namespace Patch_SexToysMasturbation
{
    public class AnimSexToyUtility
    {

        public static SexToyAnimationDef tryFindAnimation(CompSexToy sexToy, Pawn pawn)
        {

            IEnumerable<SexToyAnimationDef> options = DefDatabase<SexToyAnimationDef>.AllDefs.Where((SexToyAnimationDef x) =>
            {

                if(!sexToy.Props.requiredBodyParts.Contains(x.requiredBodyPart))
                {
                    return false;
                }

                if(x.requiredBodyPart == "vagina" && !Genital_Helper.has_vagina(pawn))
                {
                    return false;
                }

                return true;

            });

            if(options != null && options.Any())
            {
                return options.RandomElement();
            }
            else
            {
                return null;
            }

        }

    }
}


code:
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Rimworld-Animations")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Rimworld-Animations")]
[assembly: AssemblyCopyright("Copyright ©  2020")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]

// Setting ComVisible to false makes the types in this assembly not visible
// to COM components.  If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("71b05d71-67b2-4014-82cd-18c20ac0882f")]

// Version information for an assembly consists of the following four values:
//
//      Major Version
//      Minor Version
//      Build Number
//      Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]


code:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
  <PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <ProjectGuid>{71B05D71-67B2-4014-82CD-18C20AC0882F}</ProjectGuid>
    <OutputType>Library</OutputType>
    <AppDesignerFolder>Properties</AppDesignerFolder>
    <RootNamespace>Rimworld_Animations</RootNamespace>
    <AssemblyName>Rimworld-Animations</AssemblyName>
    <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
    <FileAlignment>512</FileAlignment>
    <Deterministic>true</Deterministic>
    <TargetFrameworkProfile />
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DebugSymbols>false</DebugSymbols>
    <DebugType>none</DebugType>
    <Optimize>false</Optimize>
    <OutputPath>1.4\Assemblies\</OutputPath>
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <DebugType>none</DebugType>
    <Optimize>true</Optimize>
    <OutputPath>bin\Release\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="0Harmony">
      <HintPath>..\..\..\..\workshop\content\294100\839005762\1.4\Assemblies\0Harmony.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="AlienRace">
      <HintPath>..\..\..\..\workshop\content\294100\839005762\1.4\Assemblies\AlienRace.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="Assembly-CSharp">
      <HintPath>..\..\RimWorldWin64_Data\Managed\Assembly-CSharp.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="RJW">
      <HintPath>..\rjw\1.4\Assemblies\RJW.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="RJW-ToysAndMasturbation">
      <HintPath>..\rjw-toys-and-masturbation\Assemblies\RJW-ToysAndMasturbation.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="System" />
    <Reference Include="System.Core" />
    <Reference Include="System.Xml.Linq" />
    <Reference Include="System.Data.DataSetExtensions" />
    <Reference Include="Microsoft.CSharp" />
    <Reference Include="System.Data" />
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Xml" />
    <Reference Include="UnityEngine">
      <HintPath>..\..\RimWorldWin64_Data\Managed\UnityEngine.dll</HintPath>
      <Private>False</Private>
    </Reference>
    <Reference Include="UnityEngine.CoreModule">
      <HintPath>..\..\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
      <Private>False</Private>
    </Reference>
  </ItemGroup>
  <ItemGroup>
    <Compile Include="1.4\Source\Actors\Actor.cs" />
    <Compile Include="1.4\Source\Actors\AlienRaceOffset.cs" />
    <Compile Include="1.4\Source\Actors\BodyTypeOffset.cs" />
    <Compile Include="1.4\Source\Animations\AnimationStage.cs" />
    <Compile Include="1.4\Source\Animations\Clips\BaseAnimationClip.cs" />
    <Compile Include="1.4\Source\Animations\Clips\PawnAnimationClip.cs" />
    <Compile Include="1.4\Source\Animations\Clips\ThingAnimationClip.cs" />
    <Compile Include="1.4\Source\Animations\Keyframes\Keyframe.cs" />
    <Compile Include="1.4\Source\Animations\Keyframes\PawnKeyframe.cs" />
    <Compile Include="1.4\Source\Animations\Keyframes\ThingKeyframe.cs" />
    <Compile Include="1.4\Source\Comps\CompBodyAnimator.cs" />
    <Compile Include="1.4\Source\Comps\CompProperties_BodyAnimator.cs" />
    <Compile Include="1.4\Source\Comps\CompProperties_ThingAnimator.cs" />
    <Compile Include="1.4\Source\Comps\CompThingAnimator.cs" />
    <Compile Include="1.4\Source\Defs\AnimationDef.cs" />
    <Compile Include="1.4\Source\Extensions\PawnWoundDrawerExtension.cs" />
    <Compile Include="1.4\Source\MainTabWindows\MainTabWindow_OffsetConfigure.cs" />
    <Compile Include="1.4\Source\MainTabWindows\OffsetMainButtonDefOf.cs" />
    <Compile Include="1.4\Source\MainTabWindows\WorldComponent_UpdateMainTab.cs" />
    <Compile Include="1.4\Source\Patches\Harmony_PatchAll.cs" />
    <Compile Include="1.4\Source\Patches\OtherModPatches\HarmonyPatch_AlienRace.cs" />
    <Compile Include="1.4\Source\Patches\OtherModPatches\HarmonyPatch_CSL.cs" />
    <Compile Include="1.4\Source\Patches\OtherModPatches\HarmonyPatch_DontShaveYourHead.cs" />
    <Compile Include="1.4\Source\Patches\OtherModPatches\HarmonyPatch_FacialAnimation.cs" />
    <Compile Include="1.4\Source\Patches\OtherModPatches\HarmonyPatch_HatsDisplaySelection.cs" />
    <Compile Include="1.4\Source\Patches\OtherModPatches\HarmonyPatch_ShowHairWithHats.cs" />
    <Compile Include="1.4\Source\Patches\RimworldPatches\HarmonyPatch_HeadHair.cs" />
    <Compile Include="1.4\Source\Patches\RimworldPatches\HarmonyPatch_PawnRenderer.cs" />
    <Compile Include="1.4\Source\Patches\RimworldPatches\HarmonyPatch_PawnRotation.cs" />
    <Compile Include="1.4\Source\Patches\RimworldPatches\HarmonyPatch_Pawn_DrawTracker.cs" />
    <Compile Include="1.4\Source\Patches\RimworldPatches\HarmonyPatch_SetPawnAnimatable.cs" />
    <Compile Include="1.4\Source\Patches\RimworldPatches\HarmonyPatch_Thing.cs" />
    <Compile Include="1.4\Source\Patches\RJWPatches\HarmonyPatch_PlaySexSounds.cs" />
    <Compile Include="1.4\Source\Patches\RJWPatches\HarmonyPatch_SexTick.cs" />
    <Compile Include="1.4\Source\Patches\RJWPatches\HarmonyPatch_WorkGiverSex.cs" />
    <Compile Include="1.4\Source\Patches\RJWPatches\JobDrivers\HarmonyPatch_JobDriver_JoinInBed.cs" />
    <Compile Include="1.4\Source\Patches\RJWPatches\JobDrivers\HarmonyPatch_JobDriver_SexBaseInitiator.cs" />
    <Compile Include="1.4\Source\Patches\RJWPatches\JobDrivers\HarmonyPatch_JobDriver_SexBaseReceiverLoved.cs" />
    <Compile Include="1.4\Source\Settings\AnimationSettings.cs" />
    <Compile Include="1.4\Source\Utilities\AnimationUtility.cs" />
    <Compile Include="1.4\Source\Utilities\PatchOperationAddOrReplace.cs" />
    <Compile Include="Properties\AssemblyInfo.cs" />
  </ItemGroup>
  <ItemGroup>
    <Content Include="1.1\Assemblies\Rimworld-Animations.dll" />
    <Content Include="1.4\Assemblies\Rimworld-Animations.dll" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_Beast.xml" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_Lesbian.xml" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_Masturbate.xml" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_Multi.xml" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_SexToys.xml" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_vanilla.xml" />
    <Content Include="1.4\Defs\AnimationDefs\Animations_Vanilla2.xml" />
    <Content Include="1.4\Defs\AnimationDefs\TemplateAnimation.xml" />
    <Content Include="1.4\Defs\MainTabDefs\MainButtonDef.xml" />
    <Content Include="1.4\Defs\SoundDefs\Sounds_Sex.xml" />
    <Content Include="1.4\Patches\AnimationPatchHSK.xml" />
    <Content Include="1.4\Patches\AnimationPatch_CompBodyAnimator.xml" />
    <Content Include="1.4\Patches\CompatibilityPatch_FacialAnimation.xml" />
    <Content Include="1.4\Patches\CompatibilityPatch_HCSK.xml" />
    <Content Include="1.4\Patches\CompPatches\AutoCleaner.xml" />
    <Content Include="1.4\Patches\CompPatches\CombatExtended.xml" />
    <Content Include="1.4\Patches\CompPatches\ZombieLand.xml" />
    <Content Include="1.4\Patches\RacePatches\Epona race Renaissance.xml" />
    <Content Include="1.4\Patches\RacePatches\Nyaron.xml" />
    <Content Include="1.4\Sounds\Sex\Clap_1.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_2.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_3.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_4.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_5.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_6.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_7.wav" />
    <Content Include="1.4\Sounds\Sex\Clap_8.wav" />
    <Content Include="1.4\Sounds\Sex\cum.wav" />
    <Content Include="1.4\Sounds\Sex\kucyu04.wav" />
    <Content Include="1.4\Sounds\Sex\Slime\Slimy1.wav" />
    <Content Include="1.4\Sounds\Sex\Slime\Slimy2.wav" />
    <Content Include="1.4\Sounds\Sex\Slime\Slimy3.wav" />
    <Content Include="1.4\Sounds\Sex\Slime\Slimy4.wav" />
    <Content Include="1.4\Sounds\Sex\Slime\Slimy5.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_1.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_10.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\suck_2.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_3.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_4.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_5.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_6.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_7.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_8.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Suck_9.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Swallow_1.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Swallow_2.wav" />
    <Content Include="1.4\Sounds\Sex\Suck\Swallow_3.wav" />
    <Content Include="1.4\Textures\UI\MainTab.png" />
    <Content Include="About\About.xml" />
    <Content Include="About\Manifest.xml" />
    <Content Include="Languages\PortugueseBrazilian\DefInjected\MainButtonDef\MainButtonDef.xml" />
    <Content Include="Languages\PortugueseBrazilian\DefInjected\Rimworld_Animations.AnimationDef\Animations_Beast.xml" />
    <Content Include="Languages\PortugueseBrazilian\DefInjected\Rimworld_Animations.AnimationDef\Animations_Lesbian.xml" />
    <Content Include="Languages\PortugueseBrazilian\DefInjected\Rimworld_Animations.AnimationDef\Animations_Multi.xml" />
    <Content Include="Languages\PortugueseBrazilian\DefInjected\Rimworld_Animations.AnimationDef\Animations_vanilla.xml" />
    <Content Include="LoadFolders.xml" />
  </ItemGroup>
  <ItemGroup>
    <Folder Include="1.2\" />
  </ItemGroup>
  <ItemGroup>
    <None Include="1.4\Defs\AnimationDefs\AnimationDefs.rar" />
    <None Include="1.4\Patch_HatsDisplaySelection\Patch_HatsDisplaySelection.csproj" />
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>


code:
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29905.134
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rimworld-Animations", "Rimworld-Animations.csproj", "{71B05D71-67B2-4014-82CD-18C20AC0882F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Patch_HatsDisplaySelection", "Patch_HatsDisplaySelection\Patch_HatsDisplaySelection.csproj", "{BA766964-1716-422D-A09E-29426F8EB9D5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Patch_SexToysMasturbation", "Patch_SexToysMasturbation\Patch_SexToysMasturbation.csproj", "{87763712-0536-4D5F-9EAA-520F15D4F84E}"
EndProject
Global
	GlobalSection(SolutionConfigurationPlatforms) = preSolution
		Debug|Any CPU = Debug|Any CPU
		Release|Any CPU = Release|Any CPU
	EndGlobalSection
	GlobalSection(ProjectConfigurationPlatforms) = postSolution
		{71B05D71-67B2-4014-82CD-18C20AC0882F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{71B05D71-67B2-4014-82CD-18C20AC0882F}.Debug|Any CPU.Build.0 = Debug|Any CPU
		{71B05D71-67B2-4014-82CD-18C20AC0882F}.Release|Any CPU.ActiveCfg = Release|Any CPU
		{71B05D71-67B2-4014-82CD-18C20AC0882F}.Release|Any CPU.Build.0 = Release|Any CPU
		{BA766964-1716-422D-A09E-29426F8EB9D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{BA766964-1716-422D-A09E-29426F8EB9D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
		{BA766964-1716-422D-A09E-29426F8EB9D5}.Release|Any CPU.Build.0 = Release|Any CPU
		{87763712-0536-4D5F-9EAA-520F15D4F84E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{87763712-0536-4D5F-9EAA-520F15D4F84E}.Debug|Any CPU.Build.0 = Debug|Any CPU
		{87763712-0536-4D5F-9EAA-520F15D4F84E}.Release|Any CPU.ActiveCfg = Release|Any CPU
		{87763712-0536-4D5F-9EAA-520F15D4F84E}.Release|Any CPU.Build.0 = Release|Any CPU
	EndGlobalSection
	GlobalSection(SolutionProperties) = preSolution
		HideSolutionNode = FALSE
	EndGlobalSection
	GlobalSection(ExtensibilityGlobals) = postSolution
		SolutionGuid = {7E01292A-D76F-4CA3-AA64-085B84ECD905}
	EndGlobalSection
EndGlobal


code: # ShareCloud - Share By 1-Click APK

repo_name: Apkdownload/sharecloud


code:
__pycache__/
.vscode/
utils_config.json
*.py.*.py

spam-filtering/wordcounts.csv
spam-filtering/cleaned.csv
spam-filtering/test-set.csv
spam-filtering/test-classifications.csv

repo_name: spnati/serve

path: .gitignore

license: mit


code:
stages:
    - build
    - load

workflow:
    rules:
        - if: '$CI_PROJECT_PATH == "spnati/serve-repo-v2" && $CI_COMMIT_BRANCH == "master" && $CI_PIPELINE_SOURCE == "push"'

# Sudo commands are enabled via specific sudoers command alias configuration.

build_backend:
    stage: build
    tags:
        - api
    rules:
        - changes:
            - api/docker-compose.yml
            - api/docker-compose.*.yml
            - api/analytics_server/**/*
    script:
        - cd api
        - sudo docker-compose build analytics_server

load_backend:
    stage: load
    tags:
        - api
    rules:
        - changes:
            - api/docker-compose.yml
            - api/docker-compose.*.yml
            - api/analytics_server/**/*
    environment:
        name: api-production
    script:
        - sudo systemctl restart serve-repo
        - sleep 10
        - systemctl is-active --quiet serve-repo

build_kisekae:
    stage: build
    tags:
        - kisekae-server
    rules:
        - changes:
            - api/docker-compose.yml
            - api/docker-compose.*.yml
            - api/kisekae_server/**/*
    script:
        - cd api
        - sudo docker-compose build kisekae_server

load_kisekae:
    stage: load
    tags:
        - kisekae-server
    rules:
        - changes:
            - api/docker-compose.yml
            - api/docker-compose.*.yml
            - api/kisekae_server/**/*
    environment:
        name: kisekae-production
    script:
        - sudo systemctl restart kisekae-server-v2
        - sleep 90
        - systemctl is-active --quiet kisekae-server-v2

update_static:
    stage: build
    tags:
        - api
    rules:
        - changes:
            - static/**/*
    environment:
        name: frontend-production
    script:
        - sudo rsync -av static/ /var/www/html/tools


code:
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
pillow = "~=8.0"
motor = "~=2.3"
schema = "~=0.7"
python-dateutil = "~=2.8"
"discord.py" = "~=1.6"
yaswfp = "~=0.9"
matplotlib = "~=3.3"
scikit-learn = "~=0.23"
aioredis = "~=1.3"
beautifulsoup4 = "~=4.9"
sanic = "==20.9.0"
aiohttp = "~=3.6"
werkzeug = "~=1.0"
aiofiles = "~=0.6"
sanic-cors = "~=0.10"
scipy = "~=1.5"
numpy = "~=1.19"
sentry-sdk = "==0.12.2"
aiocontextvars = "~=0.2"
prometheus-client = "~=0.7"
prometheus-async = "~=19.2"
sanic-prometheus = "~=0.2"
itsdangerous = "==2.0.0a1"
bleach = "~=3.2"
pynacl = "*"

[dev-packages]
pylint = "*"
black = "*"
rope = "*"
nltk = "*"

[requires]
python_version = "3.7"

[pipenv]
allow_prereleases = true

path: Pipfile


markers: python_full_version >= '3.5.3'

markers: python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'

markers: python_version >= '3.6'

markers: python_version >= '3.5'

markers: python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'

markers: python_version >= '3.0'

markers: python_version < '3.8'

markers: sys_platform != 'win32' and implementation_name == 'cpython'

markers: python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'

markers: python_full_version >= '3.6.1'

markers: python_version >= '3.6' and python_version < '4.0'

markers: python_version < '3.8' and implementation_name == 'cpython'


code:
FROM python:3.7
RUN mkdir -p /opt/spnati-api
WORKDIR /opt/spnati-api

RUN useradd -ms /bin/bash apiserver

RUN pip3 install pipenv
COPY ./Pipfile* /opt/spnati-api/

RUN mkdir -p /kkl_images /etc/spnati-api /var/spnati-api-prometheus /var/kkl_images
RUN chown -R apiserver:apiserver /opt/spnati-api
RUN chown -R apiserver:apiserver /etc/spnati-api
RUN chown -R apiserver:apiserver /var/spnati-api-prometheus
RUN chown -R apiserver:apiserver /var/kkl_images
RUN chown -R apiserver:apiserver /kkl_images

USER apiserver

RUN pipenv sync
COPY ./analytics_server /opt/spnati-api/analytics_server
COPY ./*.py ./*.sh /opt/spnati-api/

USER root

RUN chown -R apiserver:apiserver /opt/spnati-api/*
RUN chmod +x /opt/spnati-api/entrypoint.sh

USER apiserver

VOLUME /etc/spnati-api
VOLUME /var/kkl_images
EXPOSE 8080/tcp

ENTRYPOINT ["/opt/spnati-api/entrypoint.sh"]


code:
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
pillow = "~=8.0"
motor = "~=2.3"
schema = "~=0.7"
python-dateutil = "~=2.8"
aioredis = "~=1.3"
sanic = "==20.9.0"
aiohttp = "~=3.6"
werkzeug = "~=1.0"
aiofiles = "~=0.6"
numpy = "~=1.19"
sentry-sdk = "==0.12.2"
aiocontextvars = "~=0.2"
prometheus-client = "~=0.7"
prometheus-async = "~=19.2"
sanic-prometheus = "~=0.2.1" # {editable = true, ref = "ftr-sanic-ctx-middleware", git = "https://github.com/Arvind2222/sanic-prometheus.git"}
itsdangerous = "==2.0.0a1"
bleach = "~=3.2"
beautifulsoup4 = "~=4.9"

[dev-packages]
pylint = "*"
black = "*"
rope = "*"
nltk = "*"

[requires]
python_version = "3.7"

[pipenv]
allow_prereleases = true


markers: python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'

markers: python_full_version >= '3.5.0'

markers: python_version ~= '3.6'

markers: platform_system == 'Windows' and sys_platform == 'win32' and platform_system == 'Windows'

markers: python_version < '4.0' and python_full_version >= '3.6.1'

markers: python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'


code:
By default, the API server binds to port `8080/tcp` within this container, as an
HTTP-based service.

## Volume Configuration:

- Configuration file should be stored as `config.py` within a volume mounted to
   `/etc/spnati-api`
    - This file contains secrets (such as API keys), so it should be protected
- Uploaded image attachments for Kisekae API are stored within `/var/kkl_images`,
  which should be shared with `kisekae_server` container

## Service Dependencies:

The API server, at bare minimum, requires:

- MongoDB, for persistent data storage of reports and crowdsourcing data
- A Redis instance for ratelimiting and, optionally, communication with the Kisekae service backend.

The API server can also optionally interact with:
- A Redis instance for communication with other services via Pub/Sub.
- The SPNATI Utilities and TCNATI Discord bots.
- The Kisekae service backend.
- Sentry for error logging.


code:
from __future__ import annotations
import aiohttp

from aioredis import Redis
from aiohttp import ClientSession
from typing import Set, List, Tuple, Dict, Any, Optional
from io import BytesIO
import numpy as np
from PIL import Image
from sanic import Blueprint, response, exceptions
import urllib.parse
from bs4 import BeautifulSoup

CREATOR_DATA_CACHE_PREFIX = "creator-info-cache:"
CHARACTER_IMAGE_CACHE_PREFIX = "character-image-cache:"
CHARACTER_PERMISSIONS_PREFIX = "permissions:character:"
CREATOR_PERMISSIONS_PREFIX = "permissions:user:"

FILE_API_BASE = "https://gitgud.io/spnati/spnati/-/raw/master/"

bp = Blueprint("attribution", url_prefix="/attribution")

class CreatorNotFound(Exception):
    pass

class DiscordAPIError(Exception):
    pass

class Creator:
    def __init__(
        self,
        app,
        uid: int,
        username: str,
        discriminator: str,
        avatar: Optional[str],
        banner: Optional[str],
        accent_color: Optional[int],
    ):
        if avatar is not None and len(avatar.strip()) > 0:
            avatar = avatar.strip()
        else:
            avatar = None

        if banner is not None and len(banner.strip()) > 0:
            banner = banner.strip()
        else:
            banner = None

        if accent_color == -1:
            accent_color = None

        self.uid: int = uid
        self.username: str = username
        self.discriminator: str = discriminator
        self.avatar: Optional[str] = avatar
        self.banner: Optional[str] = banner
        self.accent_color: Optional[int] = accent_color
        self.app = app

    @classmethod
    async def load(cls, app, uid: int) -> Creator:
        redis: Redis = app.redis
        http_sess: ClientSession = app.http_session
        cache_key: str = CREATOR_DATA_CACHE_PREFIX + "creator:" + str(uid)

        cached_data = await redis.hgetall(cache_key, encoding="utf-8")
        if (cached_data is not None) and (len(cached_data) > 0):
            username = cached_data["username"]
            discriminator = cached_data["discriminator"]
            avatar = cached_data.get("avatar", None)
            banner = cached_data.get("banner", None)
            accent_color = cached_data.get("accent_color", None)
        else:
            # Not cached, fetch from Discord API:
            async with http_sess.get(
                "https://discord.com/api/v9/users/" + str(uid),
                headers={"Authorization": "Bot " + app.config["DISCORD_API_TOKEN"]},
            ) as resp:
                if resp.status >= 400 and resp.status <= 499:
                    raise CreatorNotFound(
                        "Could not retrieve user info for user {} - error {}".format(
                            uid, resp.status
                        ),
                        resp.status,
                    )
                elif resp.status >= 500 and resp.status <= 599:
                    raise DiscordAPIError(
                        "Could not retrieve user info for user {} - error {}".format(
                            uid, resp.status
                        ),
                        resp.status,
                    )

                data = await resp.json()
                username = data["username"]
                discriminator = data["discriminator"]
                avatar = data.get("avatar", None)
                banner = data.get("banner", None)
                accent_color = data.get("accent_color", None)

            tr = redis.multi_exec()
            tr.hmset_dict(
                cache_key,
                {
                    "username": username,
                    "discriminator": discriminator,
                    "avatar": avatar if avatar is not None else "",
                    "banner": banner if banner is not None else "",
                    "accent_color": accent_color if accent_color is not None else -1,
                },
            )
            tr.expire(cache_key, 86400)
            await tr.execute()

        return cls(app, uid, username, discriminator, avatar, banner, accent_color)

    @classmethod
    async def get_all_creators(cls, app) -> Tuple[List[Creator], List[Exception]]:
        redis: Redis = app.redis
        ret = []
        excs = []

        creator_ids = await app.redis.smembers(
            CREATOR_DATA_CACHE_PREFIX + "index", encoding="utf-8"
        )
        creator_ids = set(map(int, creator_ids))

        if len(creator_ids) == 0:
            async for key in redis.iscan(match=CREATOR_PERMISSIONS_PREFIX + "*"):
                key: str = key.decode("utf-8")
                uid = int(key.split(":")[-1])
                creator_ids.add(uid)

            tr = app.redis.multi_exec()
            tr.sadd(CREATOR_DATA_CACHE_PREFIX + "index", *creator_ids)
            tr.expire(CREATOR_DATA_CACHE_PREFIX + "index", 12 * 3600)
            await tr.execute()

        for uid in creator_ids:
            try:
                creator = await cls.load(app, uid)
                ret.append(creator)
            except (DiscordAPIError, CreatorNotFound) as e:
                excs.append(e)

        return ret, excs

    async def get_characters(self) -> Set[str]:
        char_list = await self.app.redis.smembers(
            CREATOR_PERMISSIONS_PREFIX + str(self.uid), encoding="utf-8"
        )
        return set(char_list)

    async def as_dict(self) -> Dict[str, Any]:
        characters = await self.get_characters()

        return {
            "id": self.uid,
            "username": self.username,
            "discriminator": self.discriminator,
            "avatar": self.avatar,
            "banner": self.banner,
            "accent_color": self.accent_color,
            "characters": list(characters),
        }

SCORECARD_IMG_WIDTH = 400
SCORECARD_IMG_HEIGHT = 400
SCORECARD_COM_Y_WIDTH = 50

def crop_scorecard_image(img):
    img = img.convert("RGBA")
    left, top, right, bottom = img.getbbox()
    height = bottom - top
    com_measure_bottom = top + 400

    focus_area = img.crop((left, top, right, com_measure_bottom))
    focus_area_bbox = focus_area.getbbox()
    weights = np.array(focus_area.convert("L")).astype(np.uint32)
    weights = np.abs(weights - np.mean(weights)).astype(np.uint32)

    rgba = np.array(focus_area)
    select = rgba[:, :, 3] > 0

    xx, yy = np.meshgrid(
        left + np.arange(rgba.shape[1]), top + np.arange(rgba.shape[0]), indexing="xy"
    )

    com_x_select = select[100:250, :]
    com_x_measure_xx = xx[100:250, :][com_x_select]
    com_x_measure_weights = weights[100:250, :][com_x_select]

    center_x = rgba.shape[1] // 2
    com_left = center_x - (SCORECARD_COM_Y_WIDTH // 2)
    com_right = center_x + (SCORECARD_COM_Y_WIDTH // 2)

    com_y_select = select[:, com_left:com_right]
    com_y_measure_yy = yy[:, com_left:com_right][com_y_select]
    com_y_measure_weights = weights[:, com_left:com_right][com_y_select]

    com_x = np.average(com_x_measure_xx, weights=com_x_measure_weights)
    com_y = np.average(com_y_measure_yy, weights=com_y_measure_weights)

    # select: np.ndarray = np.any(arr > 0, axis=-1)
    # com_x = np.mean(xx[select])
    # com_y = np.mean(yy[select])

    focus_area_right = left + focus_area_bbox[2]
    focus_area_left = left + focus_area_bbox[0]

    crop_left = round(com_x - (SCORECARD_IMG_WIDTH // 2))
    crop_right = crop_left + SCORECARD_IMG_WIDTH

    if (crop_left >= focus_area_left) and (crop_right > focus_area_right):
        crop_right = focus_area_right
        crop_left = crop_right - SCORECARD_IMG_WIDTH
    elif (crop_left < focus_area_left) and (focus_area_right >= crop_right):
        crop_left = focus_area_left
        crop_right = crop_left + SCORECARD_IMG_WIDTH

    crop_top = round(com_y - (SCORECARD_IMG_HEIGHT // 2))
    if (crop_top - top) > 0 and (crop_top - top) < 100:
        crop_top = top

    crop_top += 10
    crop_bottom = crop_top + SCORECARD_IMG_HEIGHT

    return img.crop((crop_left, crop_top, crop_right, crop_bottom))

async def get_scorecard_img(app, character: str, as_bytes=False):
    redis: Redis = app.redis
    http_session: ClientSession = app.http_session
    cached = await redis.get(CHARACTER_IMAGE_CACHE_PREFIX + character)

    if cached:
        if as_bytes:
            return cached
        else:
            return Image.open(cached, formats=["PNG"])

    async with http_session.get(
        FILE_API_BASE + "opponents/" + character + "/meta.xml"
    ) as resp:
        resp.raise_for_status()

        meta_bytes = await resp.read()
        soup = BeautifulSoup(meta_bytes)
        img_tag = soup.find("pic", recursive=True)

        if (img_tag is None) or (img_tag.string is None):
            return

    async with http_session.get(
        FILE_API_BASE + "opponents/" + character + "/" + str(img_tag.string)
    ) as resp:
        resp.raise_for_status()
        img_bytes = await resp.read()

    with BytesIO(img_bytes) as in_bio:
        with Image.open(in_bio, formats=["PNG"]) as img:
            ret = crop_scorecard_image(img)

    with BytesIO() as out_bio:
        ret.save(out_bio, format="PNG")
        img_bytes = out_bio.getvalue()

        await redis.set(
            CHARACTER_IMAGE_CACHE_PREFIX + character,
            img_bytes,
            expire=(6 * 3600),
        )

        if as_bytes:
            return img_bytes

    return ret

@bp.route("/creators/<user_id:int>")
async def get_creator_info(request, user_id: int):
    try:
        creator = await Creator.load(request.app, user_id)
    except CreatorNotFound:
        raise exceptions.NotFound("No creator with ID " + str(user_id)) from None
    except DiscordAPIError as e:
        raise exceptions.ServerError(e.args[0]) from None

    ret = await creator.as_dict()
    return response.json(ret)

@bp.route("/creators")
async def list_creators(request):
    creators, excs = await Creator.get_all_creators(request.app)
    ret_creators = []
    ret_excs = []

    for creator in creators:
        data = await creator.as_dict()
        ret_creators.append(data)

    for exc in excs:
        ret_excs.append(exc.args[0])

    return response.json({"creators": ret_creators, "errors": ret_excs})

@bp.route("/scorecard-character/<character:string>")
async def get_character_image(request, character: str):
    try:
        img_data = await get_scorecard_img(request.app, character, as_bytes=True)
        if img_data is None:
            raise exceptions.NotFound(
                "Character " + character + " has no listed selection image"
            )

        return response.raw(img_data, content_type="image/png")
    except aiohttp.ClientResponseError as e:
        if e.status == 404:
            raise exceptions.NotFound(
                "Could not find character {} (encountered error {} when fetching {})".format(
                    character, e.status, e.request_info.url
                )
            ) from None
        else:
            raise exceptions.ServerError(
                "Encountered error {} when fetching {} ".format(
                    e.status, e.request_info.url
                )
            ) from None


code:
from __future__ import annotations

from datetime import datetime
import json
import logging
from typing import Callable
import traceback

from aioredis import Redis
import bleach
from bson import ObjectId
from bson.errors import InvalidId
from motor.motor_asyncio import AsyncIOMotorCollection
from sanic import Blueprint, response, exceptions
from sanic.request import Request
from sanic.response import HTTPResponse
from schema import Schema, Optional, And, Or, SchemaError

from .situation_tags import ALL_CASES
from .discord_auth import DiscordUserInfo

MAX_TEXT_SIZE = 2000
bp = Blueprint("crowdsource", url_prefix="/crowdsource")

EDIT_TYPE_NEW = "new"
EDIT_TYPE_EXISTS = "exists"

class Sanitizer(object):
    def __init__(self, allow_empty=True, strip=True):
        self.cleaner = bleach.sanitizer.Cleaner(tags=[], attributes={}, strip=strip)
        self.allow_empty = allow_empty

    def validate(self, data) -> str:
        if isinstance(data, bytes):
            try:
                data = data.decode("utf-8")
            except UnicodeDecodeError:
                raise SchemaError("Could not decode bytes to str")
        elif not isinstance(data, str):
            raise SchemaError("Expected str, got {}".format(type(data).__name__))

        text = self.cleaner.clean(data).strip()
        if len(text) > MAX_TEXT_SIZE:
            raise SchemaError("Data too large")
        elif len(text) == 0:
            if not self.allow_empty:
                raise SchemaError("Empty strings not allowed")

        return text

vague_stage_schema = And(
    {
        "early": bool,
        "middle": bool,
        "late": bool,
        "forfeit": bool,
        "post_forfeit": bool,
    },
    lambda d: any(d.values()),
)

exact_stage_schema = And(
    {
        "0": bool,
        "1": bool,
        "2": bool,
        "3": bool,
        "4": bool,
        "5": bool,
        "6": bool,
        "7": bool,
        "8": bool,
        "9": bool,
        "10": bool,
    },
    lambda d: any(d.values()),
)

line_schema = Schema(
    {
        Optional("author", default=""): Sanitizer(),
        "text": Sanitizer(allow_empty=False, strip=False),
        "expression": Sanitizer(allow_empty=False),
        "situation": Or(*ALL_CASES),
        Optional("marker", default=""): Sanitizer(),
        Optional("notes", default=""): Sanitizer(strip=False),
        "stages": Or(vague_stage_schema, exact_stage_schema),
        Optional("targets", default=[]): And(
            [
                {
                    "character": Sanitizer(allow_empty=False),
                    "stage": And(int, lambda i: i >= 0 and i <= 10),
                    "role": Sanitizer(allow_empty=False),
                    Optional("marker", default=""): Sanitizer(),
                }
            ],
            lambda l: len(l) <= 2,
        ),
    }
)

def project_line(record):
    marker = record.get("marker", "")
    if marker is None:
        marker = ""

    status = record.get("status", None)
    if status == "":
        status = None

    ret = {
        "id": str(record["_id"]),
        "date": int(record["date"].timestamp() * 1000),
        "text": record["text"],
        "expression": record["expression"],
        "situation": record["situation"],
        "marker": marker,
        "notes": record["notes"],
        "stages": record["stages"],
        "author": record["author"],
        "status": status,
        "mr": record["mr"],
        "character": record["character"],
    }

    targets = []
    for target in record["targets"]:
        marker = target.get("marker", "")
        if marker == None:
            marker = ""

        targets.append(
            {
                "character": target["character"],
                "stage": target["stage"],
                "role": target["role"],
                "marker": marker,
            }
        )

    ret["targets"] = targets
    return ret

async def character_webedit_enabled(app, character_id: str):
    redis: Redis = app.redis
    return await redis.sismember("web-edit:enabled_characters", character_id)

async def character_webedit_type(app, character_id: str) -> str:
    redis: Redis = app.redis
    return await redis.hget(
        "web-edit:characters:" + character_id, "type", encoding="utf-8"
    )

@bp.route("/characters", methods=["GET"])
async def get_enabled_characters(request: Request):
    redis: Redis = request.app.redis
    members = await redis.smembers("web-edit:enabled_characters", encoding="utf-8")
    return response.json(sorted(members))

@bp.route("/characters/<character_id:string>", methods=["GET"])
async def get_character_data(request: Request, character_id: str):
    redis: Redis = request.app.redis
    data = await redis.hgetall("web-edit:characters:" + character_id, encoding="utf-8")
    if data is None:
        raise exceptions.NotFound("No such character " + character_id)

    return response.json(data)

@bp.route("/submissions/<character_id:string>", methods=["POST"])
async def submit_line(request: Request, character_id: str):
    coll: AsyncIOMotorCollection = request.app.mongo_client.spnati_usage_stats.crowdsourced_dialogue

    if request.json is None:
        raise exceptions.InvalidUsage("Missing JSON body")

    try:
        line_data = line_schema.validate(request.json)
    except SchemaError as e:
        raise exceptions.InvalidUsage("Schema failed to validate: " + str(e))

    character_status = await character_webedit_enabled(request.app, character_id)
    if not character_status:
        raise exceptions.Forbidden("Web editing is not enabled for this character.")

    dev_mode = request.app.config["DEV_MODE"]

    edit_type = await character_webedit_type(request.app, character_id)
    discord_user = await DiscordUserInfo.load(request)

    if edit_type == EDIT_TYPE_EXISTS and not dev_mode:
        if discord_user is None:
            raise exceptions.Unauthorized(
                "You need to log in via Discord to submit lines for this character."
            )

        if not discord_user.dev_server_member:
            raise exceptions.Forbidden(
                "You need to be part of the SPNATI Development Server to submit lines for this character."
            )

    line_data["date"] = datetime.utcnow()
    line_data["ip"] = request.ctx.client_ip
    line_data["ip_hash"] = request.ctx.hashed_ip
    line_data["session_id"] = request.ctx.session
    line_data["character"] = character_id
    line_data["mr"] = None

    if discord_user is not None:
        line_data["discord"] = discord_user.as_dict()
        line_data["author"] = "{}#{}".format(
            discord_user.username, discord_user.discriminator
        )
    else:
        line_data["discord"] = None
        if len(line_data["author"]) == 0:
            raise exceptions.InvalidUsage("Missing required parameter 'author'")

    result = await coll.insert_one(line_data)
    inserted_id = str(result.inserted_id)
    return response.json({"id": inserted_id}, status=201)

@bp.route("/submissions/<character_id:string>", methods=["GET"])
async def list_lines(request: Request, character_id: str):
    coll: AsyncIOMotorCollection = request.app.mongo_client.spnati_usage_stats.crowdsourced_dialogue
    sort_mode = -1
    start_from = 0
    limit = 60
    mr = None
    discord_id = None
    query = {"character": character_id}

    try:
        mr = int(request.args["mr"][0])
    except (KeyError, IndexError, ValueError):
        mr = None

    try:
        discord_id = int(request.args["discord"][0])
    except (KeyError, IndexError, ValueError):
        discord_id = None

    try:
        sort_mode = request.args["sort"][0]
    except (KeyError, IndexError):
        sort_mode = "desc"

    try:
        start_from = max(0, int(request.args["start"][0]))
    except (KeyError, IndexError, ValueError):
        start_from = 0

    try:
        limit = min(120, max(1, int(request.args["limit"][0])))
    except (KeyError, IndexError, ValueError):
        limit = 60

    try:
        status_filter = request.args["status"][0]
    except (KeyError, IndexError):
        status_filter = "default"

    if sort_mode == "desc":
        sort_mode = -1
    elif sort_mode == "asc":
        sort_mode = 1

    if status_filter == "default":
        query["$or"] = [
            {"status": "accepted"},
            {"status": None},
            {"status": {"$exists": False}},
        ]
    elif status_filter == "unset":
        query["$or"] = [{"status": None}, {"status": {"$exists": False}}]
    elif status_filter != "all":
        query["status"] = status_filter
    else:
        query["$or"] = [
            {"status": "accepted"},
            {"status": "rejected"},
            {"status": "submitted"},
            {"status": None},
            {"status": {"$exists": False}},
        ]

    query["mr"] = mr

    if discord_id is not None:
        query["discord.id"] = discord_id

    cursor = coll.find(query).sort("_id", sort_mode).skip(start_from).limit(limit)
    ret = []

    async for doc in cursor:
        ret.append(project_line(doc))

    return response.json(ret)

@bp.route("/lines/<line_id:string>", methods=["GET"])
async def get_line_data(request: Request, line_id: str):
    try:
        oid = ObjectId(line_id)
    except InvalidId:
        raise exceptions.InvalidUsage("Invalid line ID")

    coll: AsyncIOMotorCollection = request.app.mongo_client.spnati_usage_stats.crowdsourced_dialogue
    doc = await coll.find_one({"_id": oid})

    if doc is None:
        raise exceptions.NotFound("No such line " + str(oid))

    return response.json(project_line(doc))

@bp.route("/lines/<line_id:string>", methods=["DELETE"])
async def delete_line_data(request: Request, line_id: str):
    try:
        oid = ObjectId(line_id)
    except InvalidId:
        raise exceptions.InvalidUsage("Invalid line ID")

    dev_mode = request.app.config["DEV_MODE"]
    discord_user: DiscordUserInfo = await DiscordUserInfo.load(request)

    if discord_user is None and not dev_mode:
        raise exceptions.Unauthorized("Log in through Discord first.")

    coll: AsyncIOMotorCollection = request.app.mongo_client.spnati_usage_stats.crowdsourced_dialogue
    doc = await coll.find_one({"_id": oid})

    if doc is None:
        raise exceptions.NotFound("No such line " + str(oid))

    authorized = discord_user.moderator or dev_mode
    if doc["discord"] is not None:
        authorized = authorized or (doc["discord"]["id"] == discord_user.id)

    if not authorized:
        raise exceptions.Forbidden("You are not allowed to access this endpoint.")

    if doc["mr"] is not None:
        raise exceptions.InvalidUsage(
            "This line has already been submitted via a Merge Request; it cannot be deleted."
        )

    await coll.delete_one({"_id": oid})
    return response.empty()

@bp.route("/lines/<line_id:string>", methods=["PATCH"])
async def update_line_data(request: Request, line_id: str):
    try:
        oid = ObjectId(line_id)
    except InvalidId:
        raise exceptions.InvalidUsage("Invalid line ID")

    if not request.app.config["DEV_MODE"]:
        discord_user: DiscordUserInfo = await DiscordUserInfo.load(request)
        if discord_user is None:
            raise exceptions.Unauthorized("Log in through Discord first.")

        if not discord_user.moderator:
            raise exceptions.Forbidden("You are not allowed to access this endpoint.")

    if request.json is None:
        raise exceptions.InvalidUsage("Missing JSON body")

    data = request.json
    coll: AsyncIOMotorCollection = request.app.mongo_client.spnati_usage_stats.crowdsourced_dialogue

    # set only line `status` if no text (aka not "Edit" button)
    if "text" not in data:
        try:
            new_status = data["status"]
            if new_status not in (None, "rejected", "accepted", "submitted"):
                raise exceptions.InvalidUsage(
                    "Invalid value for 'status': must be one of null, 'rejected', 'accepted', or 'submitted'."
                )
        except KeyError:
            raise exceptions.InvalidUsage("Missing parameter 'status'.")

        result = await coll.update_one({"_id": oid}, {"$set": {"status": new_status}})

        if result.modified_count == 0:
            raise exceptions.NotFound("No such line " + str(oid))
    else:
        await coll.update_one(
            {"_id": oid},
            {
                "$set": {
                    "text": data["text"],
                    "expression": data["expression"],
                    "situation": data["situation"],
                    "stages": data["stages"],
                    "targets": data["targets"],
                    "marker": data["marker"],
                    "notes": data["notes"],
                }
            },
        )

    doc = await coll.find_one({"_id": oid})
    return response.json(project_line(doc))


code:
import json
from datetime import datetime

from sanic import Blueprint, response, exceptions
from schema import SchemaError

bp = Blueprint("data", url_prefix="/data")

def json_encode(obj):
    if isinstance(obj, datetime):
        return obj.isoformat()
    else:
        raise TypeError(repr(obj) + " is not JSON serializable")

@bp.route("/bug_reports", methods=["GET"])
async def view_bug_reports(request):
    # Valid query string filtering args:
    # - from: lower bound for report date (UTC UNIX timestamp)
    # - type: report type
    # - character: report character
    # - status : report status (open, resolved, wontfix, invalid)

    limit_ct = int(request.args.get("limit", (20,))[0])

    limit = min(100, max(1, limit_ct))

    page = int(request.args.get("page", (0,))[0])

    query = {}
    if "from" in request.args:
        from_ts = int(request.args["from"][0])
        start_dt = datetime.fromtimestamp(from_ts)
        query["date"] = {"$gte": start_dt}

    if "status" in request.args:
        filter_status = request.args["status"][0]

        if filter_status == "open":
            query["$or"] = [{"status": {"$exists": False}}, {"status": "open"}]
        else:
            query["status"] = filter_status

    if "type" in request.args:
        query["type"] = request.args["type"][0]

    if "character" in request.args:
        query["type"] = "character"
        query["character"] = request.args["character"][0]

    bug_reports = request.app.mongo_client.spnati_usage_stats.bug_reports

    cursor = (
        bug_reports.find(
            query,
            {
                "date": 1,
                "session": 1,
                "commit": 1,
                "game": 1,
                "type": 1,
                "character": 1,
                "description": 1,
                "epilogue": 1,
                "circumstances": 1,
                "player": 1,
                "table": 1,
                "jsErrors": {"$size": "$jsErrors"},
            },
        )
        .sort("date", -1)
        .skip(page * limit)
        .limit(limit)
    )
    ret = []

    async for doc in cursor:
        doc["id"] = str(doc["_id"])
        del doc["_id"]
        ret.append(doc)

    return response.json(ret, dumps=json.dumps, default=json_encode)


code:
from __future__ import annotations

from datetime import datetime, timedelta, timezone
import hashlib
import json
import logging
from typing import Optional, Dict, Any
import secrets
import urllib.parse

import aiohttp
from aioredis import Redis
from itsdangerous import Signer, BadSignature
from sanic import Blueprint, response, exceptions
from sanic.request import Request
from sanic.response import HTTPResponse

from .oauth2 import OAuth2API, OAuth2Context

SESSION_COOKIE_ID = "session"
REDIS_KEY_PREFIX = "auth:sessions:"
DISCORD_API_URL = "https://discordapp.com/api/v6/"
REQUIRED_SCOPES = "identify guilds"

cookie_signer: Signer
bp = Blueprint("auth", url_prefix="/auth")

def discord_api(api_path: str) -> str:
    if api_path[0] == "/":
        api_path = api_path[1:]

    sp = urllib.parse.urlsplit(api_path)
    clean_path = sp.path
    if len(sp.query) > 0:
        clean_path += "?" + sp.query

    return urllib.parse.urljoin(DISCORD_API_URL, clean_path)

@bp.listener("before_server_start")
async def setup_api(app, loop):
    app.discord_oauth2 = OAuth2API(
        discord_api("/oauth2/authorize"),
        discord_api("/oauth2/token"),
        discord_api("/oauth2/token/revoke"),
        app.config["OAUTH2_REDIRECT_URI"],
        app.config["DISCORD_CLIENT_ID"],
        app.config["DISCORD_CLIENT_SECRET"],
        "discord",
    )

class DiscordUserInfo(object):
    def __init__(
        self,
        user_id: int,
        username: str,
        discriminator: str,
        dev_server_member: bool,
        moderator: bool,
        ctx: OAuth2Context,
    ):
        self.id: int = user_id
        self.username: str = username
        self.discriminator: str = discriminator
        self.dev_server_member: bool = dev_server_member
        self.moderator: bool = moderator
        self.ctx: OAuth2Context = ctx

    def as_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "username": self.username,
            "discriminator": self.discriminator,
            "dev_server_member": self.dev_server_member,
            "is_moderator": self.moderator,
        }

    async def save(self):
        tr = self.ctx.redis.multi_exec()
        tr.hmset_dict(
            "sessions:users:" + self.ctx.session_id,
            id=str(self.id),
            username=self.username,
            discriminator=self.discriminator,
            dev_server_member=int(self.dev_server_member),
        )
        tr.expireat("sessions:users:" + self.ctx.session_id, self.ctx.expire_time)
        await tr.execute()

    @classmethod
    async def load(cls, req: Request) -> Optional[DiscordUserInfo]:
        discord_ctx: OAuth2Context = await req.app.discord_oauth2.load_request_context(
            req
        )

        auth = await discord_ctx.auth_header()
        if auth is None:
            # not logged in
            await discord_ctx.redis.delete("sessions:users:" + discord_ctx.session_id)
            return None

        cached = await discord_ctx.redis.hgetall(
            "sessions:users:" + discord_ctx.session_id, encoding="utf-8"
        )
        if cached is not None:
            try:
                uid = int(cached["id"])
                is_moderator = uid in req.app.config["MODERATORS"]
                return cls(
                    uid,
                    cached["username"],
                    cached["discriminator"],
                    bool(int(cached["dev_server_member"])),
                    is_moderator,
                    discord_ctx,
                )
            except KeyError:
                pass

        async with discord_ctx.http.get(
            discord_api("/users/@me"), headers=auth
        ) as resp:
            if resp.status >= 400:
                resp_text = await resp.text()
                logging.error(
                    "Could not get user info for session {}: {}".format(
                        discord_ctx.session_id, resp_text
                    )
                )

                raise exceptions.ServerError("Could not get Discord user info")

            user_data = await resp.json()

        # Get Discord guild data:
        async with discord_ctx.http.get(
            discord_api("/users/@me/guilds"), headers=auth
        ) as resp:
            if resp.status >= 400:
                resp_text = await resp.text()
                logging.error(
                    "Could not get user guild info for session {}: {}".format(
                        discord_ctx.session_id, resp_text
                    )
                )

                raise exceptions.ServerError("Could not get Discord user guild info")

            guilds = await resp.json()

        is_dev_guild_member = any(
            guild["id"] == req.app.config["PRIMARY_SERVER_ID"] for guild in guilds
        )
        is_moderator = user_data["id"] in req.app.config["MODERATORS"]

        ret = cls(
            user_data["id"],
            user_data["username"],
            user_data["discriminator"],
            is_dev_guild_member,
            is_moderator,
            discord_ctx,
        )

        await ret.save()
        return ret

@bp.route("/me", methods=["GET"])
async def get_login_data(request: Request):
    discord_user = await DiscordUserInfo.load(request)

    data = {
        "logged_in": discord_user is not None,
        "session_id": request.ctx.session,
        "dev_mode": request.app.config["DEV_MODE"],
    }

    if discord_user is not None:
        data["user_data"] = discord_user.as_dict()
    else:
        data["user_data"] = None

    return response.json(data)

@bp.route("/logout", methods=["GET"])
async def logout(request: Request):
    discord_ctx: OAuth2Context = await request.app.discord_oauth2.load_request_context(
        request
    )

    await discord_ctx.reset()
    return response.redirect(request.app.config["LOGIN_REDIRECT_TARGET"], status=303)

@bp.route("/login", methods=["GET"])
async def start_oauth2(request: Request):
    discord_ctx: OAuth2Context = await request.app.discord_oauth2.load_request_context(
        request
    )
    return await discord_ctx.start(
        REQUIRED_SCOPES, request.app.config["LOGIN_REDIRECT_TARGET"], prompt="none"
    )

@bp.route("/authorized", methods=["GET"])
async def oauth2_complete(request: Request):
    try:
        state: str = request.args["state"][0]
    except (KeyError, IndexError):
        raise exceptions.InvalidUsage("Missing required parameter 'state'")

    try:
        code: str = request.args["code"][0]
    except (KeyError, IndexError):
        raise exceptions.InvalidUsage("Missing required parameter 'code'")

    discord_ctx: OAuth2Context = await request.app.discord_oauth2.load_request_context(
        request
    )
    return await discord_ctx.redirect(code, state)