Initial commit — federated self-custodial Spark/Lightning tip bot
- grammY bot: /start, /unlock, /tip, /contact, /claim, /settings, /wallet - AES-256-GCM mnemonic encryption with scrypt key derivation - In-memory unlock sessions with background sweep - Atomic claim handling (TOCTOU-safe) - PIN rate limiting (5 attempts → 15 min lockout) - Fastify API server + Telegram Mini App (setup, unlock, send, receive, history) - One-time seed reveal via Mini App or auto-deleted DM message - Federated registry client - Docker Compose deployment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { Composer } from "grammy";
|
||||
import { type Conversation, createConversation } from "@grammyjs/conversations";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import {
|
||||
upsertChallenge,
|
||||
verifyClaim,
|
||||
deleteClaim,
|
||||
findVerifiedClaimsForUser,
|
||||
findClaimByIdentity,
|
||||
type ClaimedIdType,
|
||||
} from "../../db/claims";
|
||||
|
||||
const CHALLENGE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function generateChallengeCode(): string {
|
||||
return "spark-verify-" + randomBytes(9).toString("hex"); // 18 hex chars — 72 bits entropy
|
||||
}
|
||||
|
||||
async function claimConversation(
|
||||
conversation: Conversation<BotContext>,
|
||||
ctx: BotContext
|
||||
): Promise<void> {
|
||||
const userId = ctx.from!.id;
|
||||
const rawIdentity = (ctx.match as string | undefined)?.trim().replace(/^@/, "");
|
||||
|
||||
if (!rawIdentity) {
|
||||
await ctx.reply("Usage: /claim @username | /claim @channel | /claim @group");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!findUser(userId)) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent overwriting an in-progress challenge by a different user (DoS on claims)
|
||||
const existing = findClaimByIdentity(rawIdentity);
|
||||
if (
|
||||
existing &&
|
||||
existing.owned_by_user_id !== userId &&
|
||||
existing.challenge_expiry &&
|
||||
new Date(existing.challenge_expiry).getTime() > Date.now()
|
||||
) {
|
||||
await ctx.reply(
|
||||
`@${rawIdentity} has an active verification in progress by another user. Try again in a few minutes.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = generateChallengeCode();
|
||||
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
|
||||
|
||||
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
|
||||
|
||||
await ctx.reply(
|
||||
`To prove ownership of *@${rawIdentity}*, choose one of:\n\n` +
|
||||
`*Option A — Alt account / username:*\n` +
|
||||
`Send the code below *from @${rawIdentity}* in a DM to this bot:\n\`${code}\`\n\n` +
|
||||
`*Option B — Channel:*\n` +
|
||||
`Post the code in @${rawIdentity}, then forward that post here.\n\n` +
|
||||
`*Option C — Group (anonymous admin):*\n` +
|
||||
`Post the code in the group *as the group identity*, then forward it here.\n\n` +
|
||||
`Challenge expires in 10 minutes.`,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
// Wait for the verification message
|
||||
const verifyMsg = await conversation.waitFor(["message:text", "message:forward_origin"]);
|
||||
const msg = verifyMsg.message;
|
||||
|
||||
// ── Option A: message sent directly FROM the claimed @username ────────────
|
||||
// The message must arrive from a DIFFERENT Telegram account whose username
|
||||
// matches the claimed identity — NOT from the initiating account.
|
||||
if (msg.text === code) {
|
||||
const senderUsername = msg.from?.username?.toLowerCase();
|
||||
const senderUserId = msg.from?.id;
|
||||
|
||||
if (senderUserId !== userId && senderUsername === rawIdentity.toLowerCase()) {
|
||||
// The claimed account sent the code to us directly
|
||||
upsertChallenge(rawIdentity, "username", userId, code, expiresAt);
|
||||
verifyClaim(rawIdentity);
|
||||
await ctx.reply(`✅ @${rawIdentity} claimed successfully!`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same account sending the code — NOT proof of ownership
|
||||
await ctx.reply(
|
||||
`❌ The code must be sent *from @${rawIdentity}*, not from your own account.\n` +
|
||||
`Try /claim again if the code expired.`,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Option B / C: forwarded message from channel or group ─────────────────
|
||||
type ForwardOrigin = {
|
||||
type?: string;
|
||||
chat?: { id?: number; username?: string };
|
||||
sender_user?: { id?: number };
|
||||
};
|
||||
const origin = (msg as { forward_origin?: ForwardOrigin }).forward_origin;
|
||||
|
||||
if (!origin) {
|
||||
await ctx.reply("❌ Could not verify. The message was not sent from @" + rawIdentity + " and was not a forward. Try /claim again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardedText = (msg as { text?: string }).text?.trim() ?? "";
|
||||
if (forwardedText !== code) {
|
||||
await ctx.reply("❌ The forwarded message text does not match the challenge code. Try /claim again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (origin.type === "channel") {
|
||||
const chatUsername = origin.chat?.username?.toLowerCase();
|
||||
const chatId = String(origin.chat?.id ?? "");
|
||||
|
||||
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
|
||||
upsertChallenge(rawIdentity, "channel", userId, code, expiresAt);
|
||||
verifyClaim(rawIdentity);
|
||||
await ctx.reply(`✅ @${rawIdentity} (channel) claimed successfully!`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (origin.type === "chat") {
|
||||
const chatUsername = origin.chat?.username?.toLowerCase();
|
||||
const chatId = String(origin.chat?.id ?? "");
|
||||
|
||||
if (chatUsername === rawIdentity.toLowerCase() || chatId === rawIdentity) {
|
||||
upsertChallenge(rawIdentity, "group_admin", userId, code, expiresAt);
|
||||
verifyClaim(rawIdentity);
|
||||
await ctx.reply(`✅ @${rawIdentity} (group admin) claimed successfully!`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"❌ Verification failed — the forward origin did not match @" + rawIdentity + ". Try /claim again."
|
||||
);
|
||||
}
|
||||
|
||||
export function claimCommands(bot: Composer<BotContext>): void {
|
||||
bot.use(createConversation(claimConversation, "claim"));
|
||||
|
||||
bot.command("claim", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Use /claim in a private chat with me.");
|
||||
return;
|
||||
}
|
||||
await ctx.conversation.enter("claim");
|
||||
});
|
||||
|
||||
bot.command("unclaim", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const identity = ((ctx.match as string | undefined) ?? "").trim().replace(/^@/, "");
|
||||
if (!identity) {
|
||||
await ctx.reply("Usage: /unclaim @identity");
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = deleteClaim(identity, userId);
|
||||
await ctx.reply(removed ? `✅ @${identity} unclaimed.` : `No claim on @${identity} found.`);
|
||||
});
|
||||
|
||||
bot.command("identities", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const claims = findVerifiedClaimsForUser(userId);
|
||||
if (claims.length === 0) {
|
||||
await ctx.reply("No claimed identities. Use /claim @username to add one.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = claims.map(
|
||||
(c) => `• @${c.claimed_id} (${c.claimed_id_type}) — spending: ${c.allow_spending ? "on" : "off"}`
|
||||
);
|
||||
await ctx.reply(lines.join("\n"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Composer } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import { upsertContact, deleteContact, listContacts } from "../../db/contacts";
|
||||
import { getDb } from "../../db/schema";
|
||||
|
||||
export function contactCommands(bot: Composer<BotContext>): void {
|
||||
bot.command("contact", async (ctx) => {
|
||||
const senderId = ctx.from?.id;
|
||||
if (!senderId) return;
|
||||
|
||||
if (!findUser(senderId)) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
|
||||
const subcommand = args[0]?.toLowerCase();
|
||||
|
||||
switch (subcommand) {
|
||||
case "add":
|
||||
await handleAdd(ctx, senderId, args.slice(1));
|
||||
break;
|
||||
case "list":
|
||||
await handleList(ctx, senderId);
|
||||
break;
|
||||
case "remove":
|
||||
await handleRemove(ctx, senderId, args[1]);
|
||||
break;
|
||||
default:
|
||||
await ctx.reply(
|
||||
"Usage:\n" +
|
||||
"/contact add <alias> <@username|lightning_address>\n" +
|
||||
"/contact list\n" +
|
||||
"/contact remove <alias>"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAdd(ctx: BotContext, ownerId: number, args: string[]): Promise<void> {
|
||||
const [alias, target] = args;
|
||||
|
||||
if (!alias || !target) {
|
||||
await ctx.reply("Usage: /contact add <alias> <@username|lightning_address>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
||||
await ctx.reply("Alias must contain only letters, numbers, underscores, or hyphens.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanTarget = target.replace(/^@/, "");
|
||||
const numericId = Number(cleanTarget);
|
||||
|
||||
let targetUserId: number | null = null;
|
||||
let targetAddress: string | null = null;
|
||||
|
||||
if (!isNaN(numericId)) {
|
||||
targetUserId = numericId;
|
||||
const user = findUser(numericId);
|
||||
targetAddress = user?.spark_address ?? null;
|
||||
} else if (cleanTarget.includes("@") || cleanTarget.includes(".")) {
|
||||
// Looks like a Lightning address — store as-is
|
||||
targetAddress = cleanTarget;
|
||||
} else {
|
||||
// Username lookup
|
||||
const row = getDb()
|
||||
.prepare("SELECT id, spark_address FROM users WHERE LOWER(username) = ?")
|
||||
.get(cleanTarget.toLowerCase()) as { id: number; spark_address: string | null } | undefined;
|
||||
|
||||
if (row) {
|
||||
targetUserId = row.id;
|
||||
targetAddress = row.spark_address ?? null;
|
||||
} else {
|
||||
await ctx.reply(`User @${cleanTarget} is not registered with this bot.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
upsertContact(ownerId, alias, targetUserId, targetAddress);
|
||||
await ctx.reply(`✅ Contact saved: "${alias}" → ${target}`);
|
||||
}
|
||||
|
||||
async function handleList(ctx: BotContext, ownerId: number): Promise<void> {
|
||||
const contacts = listContacts(ownerId);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
await ctx.reply("No contacts saved. Add one with /contact add <alias> <@username>");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = contacts.map((c) => {
|
||||
const dest = c.target_address ?? `user #${c.target_user_id}`;
|
||||
return `• *${c.alias}* → ${dest}`;
|
||||
});
|
||||
|
||||
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||
}
|
||||
|
||||
async function handleRemove(ctx: BotContext, ownerId: number, alias?: string): Promise<void> {
|
||||
if (!alias) {
|
||||
await ctx.reply("Usage: /contact remove <alias>");
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = deleteContact(ownerId, alias);
|
||||
await ctx.reply(removed ? `✅ Contact "${alias}" removed.` : `No contact named "${alias}" found.`);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Composer, InlineKeyboard } from "grammy";
|
||||
import { type Conversation, createConversation } from "@grammyjs/conversations";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser, createUser } from "../../db/users";
|
||||
import { generateMnemonic, deriveSparkAddress } from "../../payments/wallet";
|
||||
import { encryptMnemonic } from "../../payments/crypto";
|
||||
import { publishToRegistry } from "../../payments/registry";
|
||||
import { createRevealToken, consumeRevealByUserId } from "../../api/onetime";
|
||||
import { config } from "../../config";
|
||||
|
||||
const MIN_PIN_LENGTH = 6;
|
||||
|
||||
async function onboardingConversation(
|
||||
conversation: Conversation<BotContext>,
|
||||
ctx: BotContext
|
||||
): Promise<void> {
|
||||
const userId = ctx.from!.id;
|
||||
const chatId = ctx.chat!.id;
|
||||
const username = ctx.from?.username ?? null;
|
||||
|
||||
// ── Step 1: PIN entry via DM, deleted immediately ─────────────────────────
|
||||
const pinPrompt = await ctx.reply(
|
||||
"Welcome! I'll set up your self-custodial Lightning wallet.\n\n" +
|
||||
"Choose a PIN (min 6 characters). This encrypts your wallet seed.\n" +
|
||||
"*Send your PIN now — I'll delete it immediately after reading:*",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
const pinMsg = await conversation.waitFor("message:text");
|
||||
const pin = pinMsg.message.text.trim();
|
||||
|
||||
await Promise.allSettled([
|
||||
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
|
||||
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
|
||||
]);
|
||||
|
||||
if (pin.length < MIN_PIN_LENGTH) {
|
||||
await ctx.reply(`PIN too short (minimum ${MIN_PIN_LENGTH} characters). Try /start again.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmPrompt = await ctx.reply("Confirm your PIN:");
|
||||
const confirmMsg = await conversation.waitFor("message:text");
|
||||
const confirm = confirmMsg.message.text.trim();
|
||||
|
||||
await Promise.allSettled([
|
||||
ctx.api.deleteMessage(chatId, confirmPrompt.message_id),
|
||||
ctx.api.deleteMessage(chatId, confirmMsg.message.message_id),
|
||||
]);
|
||||
|
||||
if (pin !== confirm) {
|
||||
await ctx.reply("PINs do not match. Try /start again.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply("Generating your wallet…");
|
||||
|
||||
// ── Step 2: create wallet ──────────────────────────────────────────────────
|
||||
const mnemonic = generateMnemonic();
|
||||
const sparkAddress = await deriveSparkAddress(mnemonic);
|
||||
const encryptedMnemonic = encryptMnemonic(mnemonic, pin);
|
||||
|
||||
createUser(userId, username, encryptedMnemonic, sparkAddress);
|
||||
await publishToRegistry(userId, sparkAddress).catch(() => null);
|
||||
|
||||
// ── Step 3: offer seed reveal choice ──────────────────────────────────────
|
||||
if (config.webappUrl) {
|
||||
const token = createRevealToken(userId, mnemonic);
|
||||
const revealUrl = `${config.webappUrl}?seedToken=${token}#seed`;
|
||||
|
||||
const keyboard = new InlineKeyboard()
|
||||
.webApp("🔐 View in Mini App (recommended)", revealUrl)
|
||||
.row()
|
||||
.text("💬 Show here in chat (auto-deleted)", "seed_in_chat");
|
||||
|
||||
await ctx.reply(
|
||||
"✅ *Wallet created!*\n\n" +
|
||||
"How would you like to view your 12-word recovery seed?\n\n" +
|
||||
"• *Mini App* — shown in a secure web view, never stored in chat\n" +
|
||||
"• *Here in chat* — message deleted automatically after 60 seconds\n\n" +
|
||||
`Your Lightning address: \`${sparkAddress}\``,
|
||||
{ parse_mode: "Markdown", reply_markup: keyboard }
|
||||
);
|
||||
// Conversation ends here — seed_in_chat callback handles the other branch
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Fallback: no Mini App configured — always show in chat ────────────────
|
||||
const seedMsg = await ctx.reply(
|
||||
"✅ *Wallet created!*\n\n" +
|
||||
"Your recovery seed (12 words):\n" +
|
||||
`\`${mnemonic}\`\n\n` +
|
||||
"📋 *Write this down offline now.* This message deletes in 60 seconds.\n\n" +
|
||||
`Your Lightning address: \`${sparkAddress}\`\n\n` +
|
||||
"/unlock — unlock wallet to send\n/balance — check balance",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
ctx.api.deleteMessage(chatId, seedMsg.message_id).catch(() => null);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
export function registerCommands(bot: Composer<BotContext>): void {
|
||||
bot.use(createConversation(onboardingConversation, "onboarding"));
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Please DM me to set up your wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = ctx.from!.id;
|
||||
if (findUser(userId)) {
|
||||
await ctx.reply(
|
||||
"You already have a wallet!\n\n" +
|
||||
"/unlock — unlock wallet\n/balance — check balance\n" +
|
||||
"/tip — send sats\n/wallet — open Mini App\n/export — export encrypted seed backup"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.conversation.enter("onboarding");
|
||||
});
|
||||
|
||||
// "Show here in chat" branch of the seed reveal choice
|
||||
bot.callbackQuery("seed_in_chat", async (ctx) => {
|
||||
const userId = ctx.from.id;
|
||||
const mnemonic = consumeRevealByUserId(userId);
|
||||
|
||||
await ctx.answerCallbackQuery();
|
||||
|
||||
if (!mnemonic || mnemonic.startsWith("\0")) {
|
||||
await ctx.reply("This seed has already been revealed or expired. Use /export to get your encrypted backup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = findUser(userId);
|
||||
const seedMsg = await ctx.reply(
|
||||
"Your recovery seed (12 words):\n" +
|
||||
`\`${mnemonic}\`\n\n` +
|
||||
"📋 *Write this down offline now.* This message deletes in 60 seconds.",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
ctx.api.deleteMessage(ctx.chat!.id, seedMsg.message_id).catch(() => null);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
bot.command("export", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Use /export in a private chat with me.");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = findUser(ctx.from!.id);
|
||||
if (!user) {
|
||||
await ctx.reply("No wallet found. Use /start to create one.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"🔐 *Encrypted wallet backup*\n\n" +
|
||||
"```\n" + user.encrypted_mnemonic + "\n```\n\n" +
|
||||
"Decrypt offline with your PIN using AES-256-GCM + scrypt.\n" +
|
||||
"Format: `salt(32B) + iv(12B) + authTag(16B) + ciphertext` — all base64.",
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Composer } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser, updateUnlockDuration } from "../../db/users";
|
||||
import { setAllowSpending, findClaimByIdentity } from "../../db/claims";
|
||||
import { publishToRegistry } from "../../payments/registry";
|
||||
import { getDb } from "../../db/schema";
|
||||
|
||||
const DURATION_MAP: Record<string, number> = {
|
||||
"15m": 15 * 60,
|
||||
"30m": 30 * 60,
|
||||
"1h": 3600,
|
||||
"4h": 4 * 3600,
|
||||
"8h": 8 * 3600,
|
||||
"24h": 24 * 3600,
|
||||
"48h": 48 * 3600,
|
||||
};
|
||||
|
||||
export function settingsCommands(bot: Composer<BotContext>): void {
|
||||
bot.command("settings", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = findUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ((ctx.match as string | undefined) ?? "").trim().split(/\s+/);
|
||||
const key = args[0]?.toLowerCase();
|
||||
|
||||
if (!key) {
|
||||
await ctx.reply(
|
||||
"Settings:\n" +
|
||||
"/settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx> — default unlock window\n" +
|
||||
"/settings registry <on|off> — federated registry opt-in\n" +
|
||||
`/settings spending <@identity> <on|off> — allow/deny spending from identity\n\n` +
|
||||
`Current unlock duration: ${formatDuration(user.unlock_duration)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "unlock_duration":
|
||||
await handleUnlockDuration(ctx, userId, args[1]);
|
||||
break;
|
||||
case "registry":
|
||||
await handleRegistry(ctx, userId, user, args[1]);
|
||||
break;
|
||||
case "spending":
|
||||
await handleSpending(ctx, userId, args[1], args[2]);
|
||||
break;
|
||||
default:
|
||||
await ctx.reply(`Unknown setting: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("history", async (ctx) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, amount_sats, status, recipient_address, created_at
|
||||
FROM pending_transactions
|
||||
WHERE initiator_user_id = ?
|
||||
ORDER BY created_at DESC LIMIT 10`
|
||||
)
|
||||
.all(userId) as {
|
||||
id: string;
|
||||
amount_sats: number;
|
||||
status: string;
|
||||
recipient_address: string | null;
|
||||
created_at: string;
|
||||
}[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await ctx.reply("No transaction history yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r) => {
|
||||
const statusIcon = r.status === "done" ? "✅" : r.status === "failed" ? "❌" : "⏳";
|
||||
const dest = r.recipient_address?.slice(0, 20) ?? "unknown";
|
||||
return `${statusIcon} ${r.amount_sats} sats → ${dest}… (${r.created_at.slice(0, 10)})`;
|
||||
});
|
||||
|
||||
await ctx.reply(lines.join("\n"));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUnlockDuration(ctx: BotContext, userId: number, value?: string): Promise<void> {
|
||||
if (!value) {
|
||||
await ctx.reply("Usage: /settings unlock_duration <15m|30m|1h|4h|8h|24h|1tx>");
|
||||
return;
|
||||
}
|
||||
|
||||
const lower = value.toLowerCase();
|
||||
|
||||
if (lower === "1tx") {
|
||||
// Store 0 as a sentinel for tx-only mode preference
|
||||
updateUnlockDuration(userId, 0);
|
||||
await ctx.reply("Default unlock mode set to: 1 transaction.");
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = DURATION_MAP[lower];
|
||||
if (!seconds) {
|
||||
await ctx.reply(`Unknown duration. Choose one of: ${Object.keys(DURATION_MAP).join(", ")}, 1tx`);
|
||||
return;
|
||||
}
|
||||
|
||||
updateUnlockDuration(userId, seconds);
|
||||
await ctx.reply(`Default unlock duration set to ${value}.`);
|
||||
}
|
||||
|
||||
async function handleRegistry(
|
||||
ctx: BotContext,
|
||||
userId: number,
|
||||
user: { spark_address: string | null },
|
||||
value?: string
|
||||
): Promise<void> {
|
||||
if (value === "on") {
|
||||
if (user.spark_address) {
|
||||
await publishToRegistry(userId, user.spark_address);
|
||||
}
|
||||
await ctx.reply("✅ Federated registry publishing enabled.");
|
||||
} else if (value === "off") {
|
||||
// TODO: send a delete request to the remote registry if supported
|
||||
await ctx.reply("Registry publishing disabled. Your entry remains in remote caches until they expire.");
|
||||
} else {
|
||||
await ctx.reply("Usage: /settings registry on|off");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSpending(
|
||||
ctx: BotContext,
|
||||
userId: number,
|
||||
identity?: string,
|
||||
value?: string
|
||||
): Promise<void> {
|
||||
if (!identity || !value) {
|
||||
await ctx.reply("Usage: /settings spending <@identity> <on|off>");
|
||||
return;
|
||||
}
|
||||
|
||||
const clean = identity.replace(/^@/, "");
|
||||
const claim = findClaimByIdentity(clean);
|
||||
|
||||
if (!claim || claim.owned_by_user_id !== userId || !claim.verified_at) {
|
||||
await ctx.reply(`No verified claim on @${clean}. Use /claim first.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allow = value.toLowerCase() === "on";
|
||||
setAllowSpending(clean, userId, allow);
|
||||
await ctx.reply(`Spending from @${clean}: ${allow ? "enabled" : "disabled"}.`);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return "1tx";
|
||||
if (seconds < 3600) return `${seconds / 60}m`;
|
||||
return `${seconds / 3600}h`;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Composer } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession, consumeTx } from "../../payments/session";
|
||||
import { sendPayment } from "../../payments/wallet";
|
||||
import { resolveRecipient } from "../../payments/registry";
|
||||
import { createPending, findPending, updateStatus, claimForRecipient, type PendingTransaction } from "../../db/pending";
|
||||
|
||||
// ─── Core payment execution ────────────────────────────────────────────────────
|
||||
|
||||
export async function executePay(
|
||||
userId: number,
|
||||
recipientAddress: string,
|
||||
amountSats: number
|
||||
): Promise<{ txId: string; feeSats: number }> {
|
||||
const session = getSession(userId);
|
||||
if (!session) throw new Error("wallet_locked");
|
||||
|
||||
const result = await sendPayment(userId, recipientAddress, amountSats);
|
||||
consumeTx(userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Process a queued pending transaction ─────────────────────────────────────
|
||||
|
||||
export async function processPending(ctx: BotContext, tx: PendingTransaction): Promise<void> {
|
||||
if (!tx.recipient_address) {
|
||||
updateStatus(tx.id, "failed");
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(tx.id, "processing");
|
||||
|
||||
try {
|
||||
await executePay(tx.initiator_user_id, tx.recipient_address, tx.amount_sats);
|
||||
updateStatus(tx.id, "done");
|
||||
|
||||
// Confirm back in the group if we know which chat
|
||||
if (tx.group_chat_id) {
|
||||
try {
|
||||
const recipientLabel =
|
||||
tx.recipient_user_id ? `user #${tx.recipient_user_id}` : tx.recipient_address;
|
||||
await ctx.api.sendMessage(
|
||||
tx.group_chat_id,
|
||||
`⚡ ${tx.amount_sats} sats sent to ${recipientLabel}`
|
||||
);
|
||||
} catch {
|
||||
// group message is best-effort
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
updateStatus(tx.id, "failed");
|
||||
console.error("[tip] processPending failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /tip command ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function tipCommands(bot: Composer<BotContext>): void {
|
||||
bot.command("tip", async (ctx) => {
|
||||
const senderId = ctx.from?.id;
|
||||
if (!senderId) return;
|
||||
|
||||
const sender = findUser(senderId);
|
||||
if (!sender) {
|
||||
await ctx.reply("You need a wallet first. DM me /start to set one up.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse amount (and optional target) from command args
|
||||
const args = (ctx.match as string | undefined)?.trim().split(/\s+/) ?? [];
|
||||
const amountSats = args[0] ? parseInt(args[0], 10) : NaN;
|
||||
|
||||
const MAX_SATS = 21_000_000 * 100_000_000; // 21M BTC in sats
|
||||
if (isNaN(amountSats) || amountSats <= 0 || amountSats > MAX_SATS) {
|
||||
await ctx.reply("Usage: /tip <amount_sats> [@user|alias]\nExample: /tip 100");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetArg = args[1] ?? null;
|
||||
const replyToUserId = ctx.message?.reply_to_message?.from?.id;
|
||||
const groupChatId = ctx.chat?.type !== "private" ? ctx.chat?.id ?? null : null;
|
||||
|
||||
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
|
||||
senderId,
|
||||
targetArg,
|
||||
replyToUserId
|
||||
);
|
||||
|
||||
if (!recipientAddress) {
|
||||
await ctx.reply(
|
||||
"Recipient not found or not registered. Share this link with them: t.me/" +
|
||||
ctx.me.username
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = getSession(senderId);
|
||||
|
||||
if (!session) {
|
||||
// Queue the transaction and ask user to unlock
|
||||
const txId = createPending({
|
||||
initiatorUserId: senderId,
|
||||
recipientUserId: recipientUserId,
|
||||
recipientAddress,
|
||||
amountSats,
|
||||
initiatedVia: "command",
|
||||
groupChatId,
|
||||
});
|
||||
|
||||
await ctx.reply(
|
||||
`Wallet is locked. DM me /unlock to send ${amountSats} sats.\nTransaction queued (expires in 2 min, ref: \`${txId.slice(0, 8)}\`).`,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
|
||||
// DM the sender if we're in a group
|
||||
if (groupChatId) {
|
||||
try {
|
||||
await ctx.api.sendMessage(
|
||||
senderId,
|
||||
`You have a pending tip of ${amountSats} sats. /unlock to confirm.`
|
||||
);
|
||||
} catch {
|
||||
// DM might be blocked — the group message already told them
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wallet unlocked — send immediately
|
||||
try {
|
||||
await executePay(senderId, recipientAddress, amountSats);
|
||||
const recipientLabel = replyToUserId
|
||||
? `@${ctx.message?.reply_to_message?.from?.username ?? replyToUserId}`
|
||||
: targetArg ?? recipientAddress;
|
||||
|
||||
await ctx.reply(`⚡ ${amountSats} sats sent to ${recipientLabel}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "unknown error";
|
||||
await ctx.reply(`❌ Payment failed: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Callback handler for claim button (inline mode without alias)
|
||||
bot.callbackQuery(/^claim:(.+)$/, async (ctx) => {
|
||||
const txId = ctx.match[1];
|
||||
const claimerId = ctx.from.id;
|
||||
|
||||
// Reject self-claims: sender should not claim their own tip
|
||||
const txCheck = findPending(txId);
|
||||
if (txCheck?.initiator_user_id === claimerId) {
|
||||
await ctx.answerCallbackQuery("You cannot claim your own tip.");
|
||||
return;
|
||||
}
|
||||
|
||||
const claimer = findUser(claimerId);
|
||||
if (!claimer?.spark_address) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: "You need a wallet to claim this. DM me /start to set one up.",
|
||||
show_alert: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic claim: only one concurrent caller can succeed (TOCTOU-safe)
|
||||
const won = claimForRecipient(txId, claimerId, claimer.spark_address);
|
||||
if (!won) {
|
||||
await ctx.answerCallbackQuery("This tip has already been claimed or expired.");
|
||||
return;
|
||||
}
|
||||
|
||||
// We now own the tx exclusively (status = 'processing' in DB)
|
||||
const tx = findPending(txId)!;
|
||||
const session = getSession(tx.initiator_user_id);
|
||||
|
||||
if (!session) {
|
||||
// Revert to awaiting_unlock so the sender can still pay on /unlock
|
||||
updateStatus(txId, "awaiting_unlock");
|
||||
await ctx.answerCallbackQuery("Sender needs to unlock their wallet first.");
|
||||
|
||||
try {
|
||||
await ctx.api.sendMessage(
|
||||
tx.initiator_user_id,
|
||||
`@${ctx.from.username ?? claimerId} wants to claim your ${tx.amount_sats} sat tip. /unlock to send.`
|
||||
);
|
||||
} catch {
|
||||
// best-effort DM
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await executePay(tx.initiator_user_id, claimer.spark_address, tx.amount_sats);
|
||||
updateStatus(txId, "done");
|
||||
|
||||
await ctx.editMessageText(
|
||||
`✅ ${tx.amount_sats} sats claimed by @${ctx.from.username ?? claimerId}`
|
||||
);
|
||||
await ctx.answerCallbackQuery("Payment sent!");
|
||||
} catch {
|
||||
updateStatus(txId, "failed");
|
||||
await ctx.answerCallbackQuery("Payment failed. Try again later.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Composer } from "grammy";
|
||||
import { type Conversation, createConversation } from "@grammyjs/conversations";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser, updateUnlockDuration } from "../../db/users";
|
||||
import { checkPinRateLimit, recordPinFailure, recordPinSuccess, RateLimitError } from "../../api/ratelimit";
|
||||
import { getWalletInfo } from "../../payments/wallet";
|
||||
import { decryptMnemonic } from "../../payments/crypto";
|
||||
import { createSession, destroySession, getSession } from "../../payments/session";
|
||||
import { connectWallet, disconnectWallet } from "../../payments/wallet";
|
||||
import { findPendingForUser } from "../../db/pending";
|
||||
import { processPending } from "./tip";
|
||||
|
||||
function parseDuration(raw: string): { seconds: number; policy: "timed" | "tx-only"; txCount?: number } | null {
|
||||
const txMatch = raw.match(/^(\d+)?tx$/i);
|
||||
if (txMatch) {
|
||||
const count = txMatch[1] ? parseInt(txMatch[1], 10) : 1;
|
||||
return { seconds: 86400, policy: "tx-only", txCount: count };
|
||||
}
|
||||
|
||||
const timeMatch = raw.match(/^(\d+)(s|m|h|d)$/i);
|
||||
if (!timeMatch) return null;
|
||||
|
||||
const value = parseInt(timeMatch[1], 10);
|
||||
const unit = timeMatch[2].toLowerCase();
|
||||
const multiplier: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
||||
return { seconds: value * multiplier[unit], policy: "timed" };
|
||||
}
|
||||
|
||||
async function unlockConversation(
|
||||
conversation: Conversation<BotContext>,
|
||||
ctx: BotContext
|
||||
): Promise<void> {
|
||||
const userId = ctx.from!.id;
|
||||
const user = findUser(userId);
|
||||
|
||||
if (!user) {
|
||||
await ctx.reply("No wallet found. Use /start first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ctx.match as string | undefined;
|
||||
let durationSeconds = user.unlock_duration;
|
||||
let policy: "timed" | "tx-only" = "timed";
|
||||
let txCount: number | undefined;
|
||||
|
||||
if (args?.trim()) {
|
||||
const parsed = parseDuration(args.trim());
|
||||
if (!parsed) {
|
||||
await ctx.reply("Unknown duration format. Examples: /unlock 4h | /unlock 1tx | /unlock 30m");
|
||||
return;
|
||||
}
|
||||
({ seconds: durationSeconds, policy } = parsed);
|
||||
txCount = parsed.txCount;
|
||||
}
|
||||
|
||||
try {
|
||||
checkPinRateLimit(userId);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) {
|
||||
await ctx.reply(`🚫 ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const chatId = ctx.chat!.id;
|
||||
const pinPrompt = await ctx.reply(
|
||||
"🔐 Enter your PIN — I'll delete it immediately after reading:"
|
||||
);
|
||||
|
||||
const pinMsg = await conversation.waitFor("message:text");
|
||||
const pin = pinMsg.message.text.trim();
|
||||
|
||||
// Delete both the prompt and the user's PIN before doing anything else
|
||||
await Promise.allSettled([
|
||||
ctx.api.deleteMessage(chatId, pinPrompt.message_id),
|
||||
ctx.api.deleteMessage(chatId, pinMsg.message.message_id),
|
||||
]);
|
||||
|
||||
let mnemonic: string;
|
||||
try {
|
||||
mnemonic = decryptMnemonic(user.encrypted_mnemonic, pin);
|
||||
recordPinSuccess(userId);
|
||||
} catch {
|
||||
recordPinFailure(userId);
|
||||
await ctx.reply("❌ Incorrect PIN.");
|
||||
return;
|
||||
}
|
||||
|
||||
await connectWallet(userId, mnemonic);
|
||||
createSession(userId, mnemonic, policy, durationSeconds, txCount);
|
||||
|
||||
const label =
|
||||
policy === "tx-only"
|
||||
? `${txCount ?? 1} transaction(s)`
|
||||
: durationSeconds >= 3600
|
||||
? `${durationSeconds / 3600}h`
|
||||
: `${durationSeconds / 60}m`;
|
||||
|
||||
await ctx.reply(`✅ Wallet unlocked for ${label}.`);
|
||||
|
||||
// Process any pending transactions
|
||||
const pending = findPendingForUser(userId);
|
||||
if (pending.length > 0) {
|
||||
await ctx.reply(`Processing ${pending.length} pending transaction(s)...`);
|
||||
for (const tx of pending) {
|
||||
await processPending(ctx, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unlockCommands(bot: Composer<BotContext>): void {
|
||||
bot.use(createConversation(unlockConversation, "unlock"));
|
||||
|
||||
bot.command("unlock", async (ctx) => {
|
||||
if (ctx.chat.type !== "private") {
|
||||
await ctx.reply("Please use /unlock in a private chat with me (for PIN security).");
|
||||
return;
|
||||
}
|
||||
await ctx.conversation.enter("unlock");
|
||||
});
|
||||
|
||||
bot.command("lock", async (ctx) => {
|
||||
const userId = ctx.from!.id;
|
||||
const had = destroySession(userId);
|
||||
await disconnectWallet(userId);
|
||||
|
||||
if (had) {
|
||||
await ctx.reply("🔒 Wallet locked.");
|
||||
} else {
|
||||
await ctx.reply("Wallet is already locked.");
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("balance", async (ctx) => {
|
||||
const userId = ctx.from!.id;
|
||||
const session = getSession(userId);
|
||||
|
||||
if (!session) {
|
||||
await ctx.reply("Wallet is locked. Use /unlock first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await getWalletInfo(userId);
|
||||
|
||||
if (!info) {
|
||||
await ctx.reply("Could not fetch balance. Try /unlock again.");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
`⚡ Balance: *${info.balanceSats} sats*\nAddress: \`${info.sparkAddress}\``,
|
||||
{ parse_mode: "Markdown" }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Context, SessionFlavor } from "grammy";
|
||||
import { ConversationFlavor } from "@grammyjs/conversations";
|
||||
|
||||
export interface SessionData {
|
||||
// intentionally empty — conversations plugin manages its own state
|
||||
}
|
||||
|
||||
export type BotContext = Context & SessionFlavor<SessionData> & ConversationFlavor;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Bot, InlineKeyboard, session } from "grammy";
|
||||
import { conversations } from "@grammyjs/conversations";
|
||||
import { run } from "@grammyjs/runner";
|
||||
import { config } from "../config";
|
||||
import { getDb } from "../db/schema";
|
||||
import { getAllUserIds, findUser, updateUsername } from "../db/users";
|
||||
import { expireStale } from "../db/pending";
|
||||
import { startSweep, stopSweep, destroySession, getAllActiveUserIds } from "../payments/session";
|
||||
import { disconnectWallet } from "../payments/wallet";
|
||||
import { startApiServer } from "../api/server";
|
||||
import type { BotContext, SessionData } from "./context";
|
||||
import { registerCommands } from "./commands/register";
|
||||
import { unlockCommands } from "./commands/unlock";
|
||||
import { tipCommands } from "./commands/tip";
|
||||
import { contactCommands } from "./commands/contact";
|
||||
import { claimCommands } from "./commands/claim";
|
||||
import { settingsCommands } from "./commands/settings";
|
||||
import { inlineTipHandler } from "./inline/tip";
|
||||
|
||||
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
getDb();
|
||||
|
||||
const bot = new Bot<BotContext>(config.botToken);
|
||||
|
||||
// ─── Middleware ───────────────────────────────────────────────────────────────
|
||||
|
||||
bot.use(session<SessionData, BotContext>({ initial: () => ({}) }));
|
||||
bot.use(conversations());
|
||||
|
||||
// Keep username up-to-date on every interaction
|
||||
bot.use(async (ctx, next) => {
|
||||
const userId = ctx.from?.id;
|
||||
if (userId) {
|
||||
const user = findUser(userId);
|
||||
if (user && ctx.from?.username !== user.username) {
|
||||
updateUsername(userId, ctx.from?.username ?? null);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerCommands(bot);
|
||||
unlockCommands(bot);
|
||||
tipCommands(bot);
|
||||
contactCommands(bot);
|
||||
claimCommands(bot);
|
||||
settingsCommands(bot);
|
||||
inlineTipHandler(bot);
|
||||
|
||||
// /wallet — open the Mini App
|
||||
bot.command("wallet", async (ctx) => {
|
||||
const webappUrl = config.webappUrl;
|
||||
if (!webappUrl) {
|
||||
await ctx.reply("Mini App is not configured. Set WEBAPP_URL in the bot environment.");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyboard = new InlineKeyboard().webApp("Open Wallet", webappUrl);
|
||||
await ctx.reply("Tap to open your wallet:", { reply_markup: keyboard });
|
||||
});
|
||||
|
||||
// ─── Error handler ────────────────────────────────────────────────────────────
|
||||
|
||||
bot.catch((err) => {
|
||||
console.error("[bot] Unhandled error:", err.message);
|
||||
console.error(err.error);
|
||||
});
|
||||
|
||||
// ─── Session sweep ────────────────────────────────────────────────────────────
|
||||
|
||||
startSweep(async (userId) => {
|
||||
await disconnectWallet(userId).catch(() => null);
|
||||
console.log(`[session] Expired for user ${userId}`);
|
||||
});
|
||||
|
||||
// ─── Stale pending transaction cleanup ────────────────────────────────────────
|
||||
|
||||
setInterval(() => {
|
||||
const count = expireStale();
|
||||
if (count > 0) console.log(`[pending] Expired ${count} stale transaction(s)`);
|
||||
}, 60_000).unref?.();
|
||||
|
||||
// ─── Startup notification ─────────────────────────────────────────────────────
|
||||
|
||||
async function notifyUsersOnRestart(): Promise<void> {
|
||||
const userIds = getAllUserIds();
|
||||
const message = "🔄 Bot restarted — your wallet session was cleared. /unlock to continue.";
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await bot.api.sendMessage(userId, message);
|
||||
} catch {
|
||||
// User may have blocked the bot or not started it yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Graceful shutdown ────────────────────────────────────────────────────────
|
||||
|
||||
async function shutdown(runner: { stop(): void }): Promise<void> {
|
||||
console.log("[bot] Shutting down...");
|
||||
runner.stop();
|
||||
stopSweep();
|
||||
for (const userId of getAllActiveUserIds()) {
|
||||
destroySession(userId);
|
||||
await disconnectWallet(userId).catch(() => null);
|
||||
}
|
||||
getDb().close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const runner = run(bot);
|
||||
|
||||
process.once("SIGINT", () => shutdown(runner));
|
||||
process.once("SIGTERM", () => shutdown(runner));
|
||||
|
||||
// Start the Mini App API server alongside the bot
|
||||
startApiServer().catch((err) => {
|
||||
console.error("[api] Failed to start:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
console.log(`[bot] ${config.botInstanceName} started.`);
|
||||
|
||||
notifyUsersOnRestart().catch((err) =>
|
||||
console.warn("[bot] Restart notification error:", err.message)
|
||||
);
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Composer, InlineKeyboard } from "grammy";
|
||||
import type { BotContext } from "../context";
|
||||
import { findUser } from "../../db/users";
|
||||
import { getSession } from "../../payments/session";
|
||||
import { resolveRecipient } from "../../payments/registry";
|
||||
import { createPending, findPending, updateStatus } from "../../db/pending";
|
||||
import { executePay } from "../commands/tip";
|
||||
|
||||
// ─── Inline query: @mybot tip 100 [alias] ────────────────────────────────────
|
||||
|
||||
export function inlineTipHandler(bot: Composer<BotContext>): void {
|
||||
bot.on("inline_query", async (ctx) => {
|
||||
const senderId = ctx.from.id;
|
||||
const text = ctx.inlineQuery.query.trim();
|
||||
|
||||
// Only handle "tip <amount> [alias]" pattern
|
||||
const match = text.match(/^tip\s+(\d+)(?:\s+(\S+))?$/i);
|
||||
|
||||
if (!match) {
|
||||
await ctx.answerInlineQuery([], {
|
||||
cache_time: 0,
|
||||
button: { text: "How to use: tip <amount> [alias]", start_parameter: "help" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const amountSats = parseInt(match[1], 10);
|
||||
const alias = match[2] ?? null;
|
||||
|
||||
if (!findUser(senderId)) {
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: "not_registered",
|
||||
title: "Wallet not set up",
|
||||
description: "DM me /start to create your wallet",
|
||||
input_message_content: { message_text: "I need to set up my wallet first." },
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!alias) {
|
||||
// No alias — post a "claim" message with a button
|
||||
const txId = createPending({
|
||||
initiatorUserId: senderId,
|
||||
recipientUserId: null,
|
||||
recipientAddress: null,
|
||||
amountSats,
|
||||
initiatedVia: "inline",
|
||||
groupChatId: null, // resolved in chosen_inline_result
|
||||
});
|
||||
|
||||
const keyboard = new InlineKeyboard().text(`⚡ Claim ${amountSats} sats`, `claim:${txId}`);
|
||||
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: `tip_claim_${txId}`,
|
||||
title: `⚡ Send ${amountSats} sats — anyone can claim`,
|
||||
description: "Tap to post. First person to tap Claim receives the payment.",
|
||||
input_message_content: {
|
||||
message_text: `⚡ ${amountSats} sats up for grabs — tap to claim!`,
|
||||
},
|
||||
reply_markup: keyboard,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve alias to an address
|
||||
const { userId: recipientUserId, address: recipientAddress } = await resolveRecipient(
|
||||
senderId,
|
||||
alias
|
||||
);
|
||||
|
||||
if (!recipientAddress) {
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: "alias_not_found",
|
||||
title: `Unknown alias: ${alias}`,
|
||||
description: "Add contacts with /contact add",
|
||||
input_message_content: {
|
||||
message_text: `Could not find alias "${alias}". Add contacts with /contact add.`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const txId = createPending({
|
||||
initiatorUserId: senderId,
|
||||
recipientUserId,
|
||||
recipientAddress,
|
||||
amountSats,
|
||||
initiatedVia: "inline",
|
||||
groupChatId: null,
|
||||
});
|
||||
|
||||
await ctx.answerInlineQuery([
|
||||
{
|
||||
type: "article",
|
||||
id: `tip_direct_${txId}`,
|
||||
title: `⚡ Send ${amountSats} sats to ${alias}`,
|
||||
description: "Tap to send",
|
||||
input_message_content: {
|
||||
message_text: `⚡ Sending ${amountSats} sats to ${alias}…`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ─── Chosen inline result: actually execute the payment ──────────────────
|
||||
|
||||
bot.on("chosen_inline_result", async (ctx) => {
|
||||
const resultId = ctx.chosenInlineResult.result_id;
|
||||
const senderId = ctx.from.id;
|
||||
|
||||
// Only handle direct tip results (not claim results — those use callback buttons)
|
||||
if (!resultId.startsWith("tip_direct_")) return;
|
||||
|
||||
const txId = resultId.replace("tip_direct_", "");
|
||||
const tx = findPending(txId);
|
||||
|
||||
if (!tx || tx.status !== "awaiting_unlock") return;
|
||||
|
||||
const session = getSession(senderId);
|
||||
|
||||
if (!session) {
|
||||
// Wallet locked — update tx and notify sender
|
||||
updateStatus(txId, "awaiting_unlock"); // already set, but make explicit
|
||||
|
||||
try {
|
||||
await ctx.api.sendMessage(
|
||||
senderId,
|
||||
`Wallet locked. /unlock to send ${tx.amount_sats} sats (pending for 2 min).`
|
||||
);
|
||||
} catch {
|
||||
// DM might be blocked
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(txId, "processing");
|
||||
|
||||
try {
|
||||
await executePay(senderId, tx.recipient_address!, tx.amount_sats);
|
||||
updateStatus(txId, "done");
|
||||
|
||||
// Edit the posted message to show success (requires inline_message_id)
|
||||
const inlineMsgId = ctx.chosenInlineResult.inline_message_id;
|
||||
if (inlineMsgId) {
|
||||
try {
|
||||
await ctx.api.editMessageTextInline(
|
||||
inlineMsgId,
|
||||
`✅ ${tx.amount_sats} sats sent!`
|
||||
);
|
||||
} catch {
|
||||
// best-effort edit
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
updateStatus(txId, "failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user