using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
[ApiController]
[Authorize]
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// Key prefix for "player X redeemed one of player Y's invites".
/// Row layout is PlayerId = the invitee,
/// Key = externalinvite:redeemed:{kind}:{inviterAccountId},
/// Value = redemption timestamp. The referrer endpoints below read
/// these rows; the invite-claim path is what writes them.
private const string RedeemedPrefix = "externalinvite:redeemed:";
/// Records an out-of-game friend invite aimed at a platform (Steam,
/// Oculus, …) account.
///
/// The client only ever sends the single form field platformId
/// (RecNet.Runtime/OMHDDLFIPLP.txt:876) — platform keeps its default
/// so the older callers that do send it still bind.
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
public async Task CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
{
var code = NewInviteCode();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:platform:{code}",
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
var result = Success();
result["InviteCode"] = code;
result["InviteUrl"] = url;
result["Platform"] = platform;
result["PlatformId"] = platformId ?? string.Empty;
return Ok(result);
}
/// Records an SMS friend invite.
///
/// Form fields are phoneNumber, friendCode and
/// senderName (OMHDDLFIPLP.txt:497/505/515) — there is no "message"
/// field, so the friend code that the text is supposed to carry has to be
/// persisted from friendCode or it is lost.
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
public async Task SendTextMessageInvite(
[FromForm] string? phoneNumber,
[FromForm] string? friendCode,
[FromForm] string? senderName)
{
var code = NewInviteCode();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:text:{code}",
Value = $"{phoneNumber ?? string.Empty}|{friendCode ?? string.Empty}|{senderName ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var result = Success();
result["InviteCode"] = code;
result["PhoneNumber"] = phoneNumber ?? string.Empty;
return Ok(result);
}
/// Ids of the accounts that referred the caller through a platform
/// invite.
///
/// The response is a bare array of account ids — the issuing method is
/// FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB()
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning
/// objects ({InviteCode, Kind, Value, CreatedAt}) made the strict
/// Int32 reader throw as soon as any row existed, which faulted
/// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers
/// (SessionManager.txt:31439) during the login bootstrap.
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task GetPlatformReferrers()
=> Ok(await ReferrerIdsAsync("platform"));
/// Same bare-id-array contract as
/// (OMHDDLFIPLP.txt:998-1071), for
/// text-message invites.
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task GetTextMessageReferrers()
=> Ok(await ReferrerIdsAsync("text"));
/// Account ids recorded as having referred the caller with an
/// invite of the given kind. The inviter id is the trailing key segment of
/// the redemption row, so no id can leak in from an invite's own payload
/// (a numeric platformId used to be misread as an account id).
private async Task> ReferrerIdsAsync(string kind)
{
var prefix = RedeemedPrefix + kind + ":";
var keys = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith(prefix))
.OrderByDescending(s => s.Id)
.Select(s => s.Key)
.ToListAsync();
return keys
.Select(k => int.TryParse(k[prefix.Length..], out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
}
private static string NewInviteCode() => Guid.NewGuid().ToString("N")[..10];
/// Both write routes await FGLDKEJLAKB<DJMHAFPGLLN> and
/// hand it to BFDGEKKNAPB.ExpandRecNetResult, whose lambda pops the
/// Error string as an error toast whenever Success is false
/// (BFDGEKKNAPB_NestedType___c.txt:61 <ExpandRecNetResult>b__2_0).
/// Utf8Json leaves absent members at their default, so a response without
/// these two keys reads as Success=false and every successful invite raised
/// an empty error toast. Key names are literal from the generated formatter
/// (LMCCNLLHLCJ.txt:191 "Success", :210 "Error"; it also accepts the
/// camelCase aliases at :202/:218, and skips members it does not know, so
/// the per-route extras below are inert).
private static Dictionary Success() => new(StringComparer.Ordinal)
{
["Success"] = true,
["Error"] = string.Empty,
};
}