Last active 1 month ago

Revision 643cec50fd0901d2d8f9f0733e0383f831a1d58e

borrow.ts Raw
1import { Client, TextChannel } from "discord.js";
2import { BorrowRequest } from "../types";
3import { cfg } from "./config";
4import { getCharacterByName } from "./characters";
5
6// Active borrow requests (pending accept/decline)
7const pendingRequests: Map<string, BorrowRequest> = new Map(); // key: `${ownerKey}:${requesterKey}`
8
9// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start
10const sessionBorrows: Map<string, { ownerKey: string; charName: string }> = new Map();
11
12// DM message IDs for updating borrow request messages
13const borrowDmMessages: Map<string, { channelId: string; messageId: string }> = new Map();
14
15function requestKey(ownerKey: string, requesterKey: string): string {
16 return `${ownerKey}:${requesterKey}`;
17}
18
19export function getPendingRequest(ownerKey: string, requesterKey: string): BorrowRequest | null {
20 return pendingRequests.get(requestKey(ownerKey, requesterKey)) ?? null;
21}
22
23export function getPendingRequestByKey(key: string): BorrowRequest | null {
24 return pendingRequests.get(key) ?? null;
25}
26
27export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] {
28 return [...pendingRequests.values()].filter((r) => r.ownerKey === ownerKey);
29}
30
31export function addPendingRequest(request: BorrowRequest): void {
32 const key = requestKey(request.ownerKey, request.requesterKey);
33 const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0;
34
35 pendingRequests.set(key, request);
36
37 if (expiry > 0) {
38 setTimeout(() => {
39 if (pendingRequests.get(key)?.requestedAt === request.requestedAt) {
40 pendingRequests.delete(key);
41 console.log(`[borrow] Request ${key} expired.`);
42 }
43 }, expiry);
44 }
45}
46
47export function removePendingRequest(ownerKey: string, requesterKey: string): void {
48 pendingRequests.delete(requestKey(ownerKey, requesterKey));
49}
50
51export function storeDmMessage(ownerKey: string, requesterKey: string, channelId: string, messageId: string): void {
52 borrowDmMessages.set(requestKey(ownerKey, requesterKey), { channelId, messageId });
53}
54
55export function getDmMessage(ownerKey: string, requesterKey: string): { channelId: string; messageId: string } | null {
56 return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null;
57}
58
59// Session borrow management
60export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void {
61 sessionBorrows.set(requesterKey, { ownerKey, charName });
62}
63
64export function getSessionBorrow(requesterKey: string): { ownerKey: string; charName: string } | null {
65 return sessionBorrows.get(requesterKey) ?? null;
66}
67
68export function clearSessionBorrows(): void {
69 sessionBorrows.clear();
70 borrowDmMessages.clear();
71}
72
73// Check if a user can use a character (owns it or has share/borrow access)
74export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean {
75 if (requesterKey === ownerKey) return true;
76
77 // Check persistent share
78 const char = getCharacterByName(ownerKey, charName);
79 if (char?.sharedWith?.includes(requesterKey)) return true;
80
81 // Check session borrow
82 const borrow = getSessionBorrow(requesterKey);
83 if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true;
84
85 return false;
86}
87
88// Send borrow request DM to owner, fall back to poll channel ephemeral
89export async function sendBorrowRequestDM(
90 client: Client,
91 ownerDiscordId: string,
92 requesterDisplayName: string,
93 ownerKey: string,
94 requesterKey: string,
95 charName: string,
96 charClass: string,
97 charLevel: number,
98 fallbackChannel?: TextChannel
99): Promise<void> {
100 const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = await import("discord.js");
101
102 const content = `🔔 **Borrow Request**\n**${requesterDisplayName}** wants to borrow **«${charName}»** (${charClass} · Lv${charLevel}) for tonight's TG.`;
103
104 const acceptBtn = new ButtonBuilder()
105 .setCustomId(`borrow_accept:${ownerKey}:${requesterKey}`)
106 .setLabel("✅ Accept")
107 .setStyle(ButtonStyle.Success);
108
109 const declineBtn = new ButtonBuilder()
110 .setCustomId(`borrow_decline:${ownerKey}:${requesterKey}`)
111 .setLabel("❌ Decline")
112 .setStyle(ButtonStyle.Danger);
113
114 const row = new ActionRowBuilder<ButtonBuilder>().addComponents(acceptBtn, declineBtn);
115
116 try {
117 const ownerUser = await client.users.fetch(ownerDiscordId);
118 const dm = await ownerUser.createDM();
119 const msg = await dm.send({ content, components: [row] });
120 storeDmMessage(ownerKey, requesterKey, dm.id, msg.id);
121 } catch {
122 // DM failed — fall back to poll channel ephemeral
123 if (fallbackChannel) {
124 await fallbackChannel.send({
125 content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`,
126 });
127 }
128 }
129}
130
131// Update DM after accept/decline to disable buttons
132export async function updateBorrowDM(
133 client: Client,
134 ownerKey: string,
135 requesterKey: string,
136 accepted: boolean
137): Promise<void> {
138 const dm = getDmMessage(ownerKey, requesterKey);
139 if (!dm) return;
140 try {
141 const channel = await client.channels.fetch(dm.channelId) as any;
142 const message = await channel.messages.fetch(dm.messageId);
143 const status = accepted ? "✅ Accepted" : "❌ Declined";
144 await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] });
145 } catch {
146 // DM may have been deleted, ignore
147 }
148}
149
150// Returns the effective active character for a user
151// Priority: session borrow → persistent preference → own active character
152export function getEffectiveCharacter(usermapKey: string): { char: any; borrowedFrom: string | null } {
153 const { getActiveCharacter, getCharacterByName } = require("./characters");
154
155 // 1. Session borrow (temporary, resets on poll start)
156 const borrow = getSessionBorrow(usermapKey);
157 if (borrow) {
158 const char = getCharacterByName(borrow.ownerKey, borrow.charName);
159 if (char) return { char, borrowedFrom: borrow.ownerKey };
160 }
161
162 // 2. Persistent preference (set when switching to a shared char, survives restarts)
163 const pref = getPersistentPreference(usermapKey);
164 if (pref) {
165 const char = getCharacterByName(pref.ownerKey, pref.charName);
166 if (char) return { char, borrowedFrom: pref.ownerKey };
167 // Char no longer exists or share was revoked — clear preference
168 clearPersistentPreference(usermapKey);
169 }
170
171 // 3. Own active character
172 const char = getActiveCharacter(usermapKey);
173 return { char: char ?? null, borrowedFrom: null };
174}