您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
基于 $V2EX 销毁机制的聊天室
// ==UserScript== // @name V2Chat // @namespace http://tampermonkey.net/ // @version 1.1 // @description 基于 $V2EX 销毁机制的聊天室 // @match https://*.v2ex.com/* // @match https://v2ex.com/* // @require https://unpkg.com/@solana/web3.js@latest/lib/index.iife.min.js // @run-at document-end // @license MIT // ==/UserScript== (async function () { 'use strict'; const { Connection, PublicKey, Transaction, TransactionInstruction } = solanaWeb3; const V2EX_TOKEN_MINT = "9raUVuzeWUk53co63M4WXLWPWE4Xc6Lpn7RS9dnkpump"; const BURN_ATA_ADDRESS = "5Q9cjTsKBhm29KwUKFwsAiKFBT263ASBb1LiHdXFWYKW"; // 1nc1nerator11111111111111111111111111111111 const RPC_URL = "https://solana-rpc.publicnode.com"; const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); const MAX_LOAD_COUNT = 15; const styleMap = { 1: 'color:#ffffff;', 10: 'color:#1eff00;', 100: 'color:#0070dd;', 1000: 'color:#a335ee;', 10000: 'color:#ff8000;' }; const chatBox = document.createElement('div'); chatBox.id = 'geek-chat'; chatBox.style.cssText = ` position: fixed; bottom: 20px; left: 20px; width: 400px; background: rgba(0,0,0,0.85); border-radius: 8px; color: white; box-shadow: 0 0 12px rgba(0,0,0,0.6); z-index: 9999; overflow: hidden; `; chatBox.innerHTML = ` <div id="chat-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:#111;border-bottom:1px solid #333;font-weight:bold;"> <div>V2EX Chat</div> <button id="minimize-btn" style="cursor:pointer;font-size:16px;color:#aaa;background:none;border:none;">−</button> </div> <div id="chat-body"> <div id="chat-messages" style="max-height:300px;overflow-y:auto;padding:10px;background:#111;"></div> <div id="chat-input" style="display:flex;gap:6px;border-top:1px solid #333;padding:8px;background:#111;align-items:center;"> <div id="value-select-wrapper" style="position:relative;" title="Select message value"> <button id="current-value-btn" data-value="1" style="border:none;border-radius:4px;padding:6px 10px;font-size:14px;font-weight:bold;cursor:pointer;color:black;min-width:50px;text-align:center;background:white;">1</button> <div id="value-buttons" style="position:absolute;bottom:36px;left:0;border-radius:4px;display:flex;gap:4px;opacity:0;pointer-events:none;transition:opacity 0.15s;white-space:nowrap;z-index:10;"> ${[1, 10, 100, 1000, 10000].map(v => ` <button class="value-btn" data-value="${v}" style="border:1px solid transparent;border-radius:4px;padding:6px 10px;font-size:14px;cursor:pointer;color:black;font-weight:bold;background-${styleMap[v]}${v === 1 ? 'border-color:white;' : ''}">${v}</button> `).join('')} </div> </div> <input type="text" id="message-text" placeholder=">_ Type your message..." maxlength="200" style="flex:1;padding:6px 8px;background:transparent;border:1px solid #444;color:white;outline:none;font-size:14px;" autocomplete="off" /> <button class="send-btn" style="background:#333;border:1px solid #555;color:white;padding:6px 12px;cursor:pointer;font-size:14px;">Send</button> </div> </div> `; document.body.appendChild(chatBox); const chatHeader = chatBox.querySelector('#chat-header'); const currentValueBtn = chatBox.querySelector('#current-value-btn'); const valueBtns = chatBox.querySelectorAll('.value-btn'); const valuePanel = chatBox.querySelector('#value-buttons'); const wrapper = chatBox.querySelector('#value-select-wrapper'); const input = chatBox.querySelector('#message-text'); const msgArea = chatBox.querySelector('#chat-messages'); const sendBtn = chatBox.querySelector('.send-btn'); const minimizeBtn = chatBox.querySelector('#minimize-btn'); const chatBody = chatBox.querySelector('#chat-body'); let selectedValue = 1; function showValuePanel() { valuePanel.style.opacity = '1'; valuePanel.style.pointerEvents = 'auto'; } function hideValuePanel() { valuePanel.style.opacity = '0'; valuePanel.style.pointerEvents = 'none'; } wrapper.addEventListener('mouseenter', showValuePanel); wrapper.addEventListener('mouseleave', () => { // 延迟判断,避免快速切换时闪烁 setTimeout(() => { if (!wrapper.matches(':hover') && !valuePanel.matches(':hover')) { hideValuePanel(); } }, 100); }); valuePanel.addEventListener('mouseenter', showValuePanel); valuePanel.addEventListener('mouseleave', () => { setTimeout(() => { if (!wrapper.matches(':hover') && !valuePanel.matches(':hover')) { hideValuePanel(); } }, 100); }); valueBtns.forEach(btn => { btn.addEventListener('click', () => { valueBtns.forEach(b => b.style.borderColor = 'transparent'); btn.style.borderColor = 'white'; selectedValue = parseInt(btn.dataset.value); currentValueBtn.dataset.value = selectedValue; currentValueBtn.textContent = selectedValue; currentValueBtn.style = `border:none;border-radius:4px;padding:6px 10px;font-size:14px;font-weight:bold;cursor:pointer;min-width:50px;text-align:center;background-${styleMap[selectedValue]}`; }); }); let sendLoadingIntervalId = null; function startSendLoading() { if (sendLoadingIntervalId) return; const states = ['Send', 'Send.', 'Send..', 'Send...']; let index = 0; sendLoadingIntervalId = setInterval(() => { index = (index + 1) % states.length; sendBtn.textContent = states[index]; }, 500); }; function stopSendLoading(buttonId) { if (sendLoadingIntervalId) { clearInterval(sendLoadingIntervalId); sendLoadingIntervalId = null; } sendBtn.textContent = 'Send' }; let myUserName = null; async function sendMessage() { const text = input.value.trim(); if (!text) return; if (sendLoadingIntervalId) return; startSendLoading(); if (!myUserName) { const names = await findMemberName(); if (!names || names.length == 0) { alert('发送失败'); stopSendLoading(); return; } myUserName = names[0]; } if (!confirm(`确定消耗 ${currentValueBtn.dataset.value} $V2EX 发送消息吗\n${myUserName}\n${text}`)) { stopSendLoading(); return; } const memo = { u: myUserName, m: text }; const success = await burnWithMessage(currentValueBtn.dataset.value, JSON.stringify(memo)); if (success) { alert('发送成功'); input.value = ''; input.focus(); updateAndShowMessage(); msgArea.scrollTop = msgArea.scrollHeight; } stopSendLoading(); } sendBtn.addEventListener('click', sendMessage); input.addEventListener('keydown', e => { if (e.key === 'Enter') sendMessage(); }); minimizeBtn.addEventListener('click', () => { if (chatBody.style.display === 'none') { chatBody.style.display = 'block'; minimizeBtn.textContent = '−'; } else { chatBody.style.display = 'none'; minimizeBtn.textContent = '+'; } }); async function connectWallet() { const provider = window.phantom?.solana; if (!provider?.isPhantom) { return null; } try { const resp = await provider.connect(); return { provider, publicKey: new PublicKey(resp.publicKey.toString()) }; } catch (err) { return null; } } async function getAssociatedTokenAddress(walletPublicKey, mintPublicKey) { const connection = new Connection(RPC_URL, "confirmed"); try { const response = await connection.getProgramAccounts(TOKEN_PROGRAM_ID, { filters: [ { dataSize: 165 }, { memcmp: { offset: 0, bytes: mintPublicKey.toBase58() } }, { memcmp: { offset: 32, bytes: walletPublicKey.toBase58() } }, ], }); return response.length > 0 ? response[0].pubkey.toBase58() : null; } catch (err) { console.error(err); console.log("query ata address failed: " + err.message); } } function createTransferInstructionData(amount) { const data = new Uint8Array(9); data[0] = 3; const amountBigInt = BigInt(amount); for (let i = 0; i < 8; i++) { data[i + 1] = Number((amountBigInt >> BigInt(i * 8)) & BigInt(0xff)); } return data; } async function burnWithMessage(tokenAmount, message) { try { await connectWallet(); } catch (e) { alert('Phantom 钱包初始化失败'); return false; } const connection = new Connection(RPC_URL, "confirmed"); const mintAddress = V2EX_TOKEN_MINT; let success = false; try { const { provider, publicKey } = await connectWallet(); const amount = Math.floor(tokenAmount * Math.pow(10, 6)); let fromATA = await getAssociatedTokenAddress(publicKey, new PublicKey(mintAddress)); if (!fromATA) { throw new Error('你暂未创建 $V2EX 代币地址'); } const instructions = []; instructions.push( new TransactionInstruction({ keys: [ { pubkey: new PublicKey(fromATA), isSigner: false, isWritable: true }, { pubkey: new PublicKey(BURN_ATA_ADDRESS), isSigner: false, isWritable: true }, { pubkey: publicKey, isSigner: true, isWritable: false }, ], programId: TOKEN_PROGRAM_ID, data: createTransferInstructionData(amount), }) ); instructions.push( new TransactionInstruction({ keys: [], programId: MEMO_PROGRAM_ID, data: new TextEncoder().encode(message) }) ) const transaction = new Transaction(); instructions.forEach((instruction) => transaction.add(instruction)); const { blockhash } = await connection.getLatestBlockhash(); transaction.recentBlockhash = blockhash; transaction.feePayer = publicKey; const { signature } = await provider.signAndSendTransaction(transaction); const timestamp = Math.floor(Date.now() / 1000); allMessage[signature] = { signature: signature, amount: tokenAmount, blockTime: timestamp, memo: JSON.parse(message), _isTemp: true }; success = true; } catch (err) { console.log('send message failed: ' + err.message); alert('发送失败: ' + err.message); } return success; } async function fetchMemoTokenTransfersRaw(address, limit = 10, before = null) { const endpoint = RPC_URL; // JSON-RPC 请求函数 async function rpc(method, params) { const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }); const json = await res.json(); return json.result; } // 尝试从 memo 中提取 JSON 段 function extractValidMemo(memoStr) { if (!memoStr) return null; // 尝试提取所有可能的 JSON 段(第一个 { 到最后一个 }) const start = memoStr.indexOf("{"); const end = memoStr.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) return null; const possibleJson = memoStr.slice(start, end + 1); try { const parsed = JSON.parse(possibleJson); delete parsed.size; return parsed; } catch { return null; } } // 计算 token 转账数量 function getTransferredTokenAmount(meta) { const pre = meta?.preTokenBalances || []; const post = meta?.postTokenBalances || []; for (let i = 0; i < pre.length; i++) { const preBal = pre[i]; const postBal = post.find(pb => pb.accountIndex === preBal.accountIndex && pb.mint === preBal.mint && pb.owner === preBal.owner ); if (postBal) { const delta = parseFloat(postBal.uiTokenAmount.uiAmount) - parseFloat(preBal.uiTokenAmount.uiAmount); if (delta !== 0) { return Math.abs(delta); } } } return 0; } // Step 1: 获取签名列表 const sigParams = [address, { limit }]; if (before) sigParams[1].before = before; const signatures = await rpc("getSignaturesForAddress", sigParams); const filtered = signatures.filter(s => s.memo && extractValidMemo(s.memo)); const results = []; // Step 2: 遍历有效交易 for (const sig of filtered) { if (allMessage[sig.signature] && !allMessage[sig.signature]._isTemp) continue; const tx = await rpc("getTransaction", [sig.signature, { encoding: "jsonParsed" }]); if (!tx || !tx.meta || tx.meta.err) continue; const memoJson = extractValidMemo(sig.memo); if (!memoJson) continue; const amount = getTransferredTokenAmount(tx.meta); results.push({ signature: sig.signature, memo: memoJson, amount, blockTime: sig.blockTime }); } return results; } async function findMemberName() { const url = `${window.location.protocol}//${window.location.host}/`; try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const toolsElement = doc.querySelector('.tools'); if (toolsElement) { const memberLinks = Array.from(toolsElement.querySelectorAll('a')) .filter(link => link.getAttribute('href').startsWith('/member/')) .map(link => link.textContent); console.log('find member name', memberLinks); return memberLinks; } else { return []; } } catch (err) { console.log('find member name error: ' + err.message); return []; } } function getColorByAmount(amount) { if (amount < 10) return styleMap[1]; if (amount < 100) return styleMap[10]; if (amount < 1000) return styleMap[100]; if (amount < 10000) return styleMap[1000]; return styleMap[10000]; } function getTimeFromTimestamp(timestamp) { const date = new Date(timestamp); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; } let allMessage = {}; function updateAndShowMessage(data) { data?.forEach(newMsg => { allMessage[newMsg.signature] = newMsg; }); msgArea.innerHTML = ''; const entries = Object.entries(allMessage); entries.sort((a, b) => a[1].blockTime - b[1].blockTime); entries.forEach(entry => { const msg = entry[1]; const chat = document.createElement('div'); chat.style = `margin:2px 0;${getColorByAmount(msg.amount)}word-break: break-word;line-height: 1.4;`; chat.innerHTML = `[ ${getTimeFromTimestamp(msg.blockTime * 1000)} ][ <a href="/member/${msg.memo.u}" target="_blank" style="color: inherit;">${msg.memo.u}</a> ] ${msg.memo.m}`; msgArea.appendChild(chat); }); } async function loadNewMessage() { const chatMessages = await fetchMemoTokenTransfersRaw(BURN_ATA_ADDRESS, MAX_LOAD_COUNT); updateAndShowMessage(chatMessages); } let isLoadingMore = false; msgArea.addEventListener('scroll', async () => { if (msgArea.scrollTop === 0 && !isLoadingMore) { isLoadingMore = true; const entries = Object.entries(allMessage); entries.sort((a, b) => a[1].blockTime - b[1].blockTime); const chatMessages = await fetchMemoTokenTransfersRaw(BURN_ATA_ADDRESS, MAX_LOAD_COUNT, entries[0][0]); updateAndShowMessage(chatMessages); isLoadingMore = false; } }); chatBody.style.display = 'none'; minimizeBtn.textContent = '+'; const initMsgDiv = document.createElement('div'); initMsgDiv.style = `margin:2px 0;color:grey;word-break: break-word;line-height: 1.4;`; initMsgDiv.textContent = `[System] Loading message...`; msgArea.appendChild(initMsgDiv); const firstMessage = await fetchMemoTokenTransfersRaw(BURN_ATA_ADDRESS, MAX_LOAD_COUNT); updateAndShowMessage(firstMessage); msgArea.scrollTop = msgArea.scrollHeight; setInterval(() => { loadNewMessage(); }, 15 * 1000); })();