码桶
发现社区成员的开源项目
index.ts15.5 KB
import { Hono } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
import { getDb } from './db';
import { parseMeetingText } from './lib/parser';
import { generateUniqueNiceSlug } from './lib/slug-generator';
import { GLOBAL_STYLES } from './styles';
import { eq, desc, gt, and } from 'drizzle-orm';
type Bindings = {
DB?: D1Database;
};
type Variables = {
sessionId: string;
};
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
// expires_at 字段保留(方便未来扩展),但靓号链接永久有效,不做过滤
const PERMANENT_EXPIRES_AT = new Date('2099-12-31T23:59:59Z');
// HTML 转义:防止 XSS
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 输入长度上限
const MAX_INPUT_LENGTH = 5000;
// 确保 Session Cookie
app.use('*', async (c, next) => {
let sessionId = getCookie(c, 'mf_session');
if (!sessionId) {
sessionId = 's_' + Math.random().toString(36).substring(2) + Date.now().toString(36);
setCookie(c, 'mf_session', sessionId, {
path: '/',
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: 'Lax',
});
}
c.set('sessionId', sessionId);
await next();
});
// 1. 首页:文本框输入与历史
app.get('/', async (c) => {
const dbContext = getDb(c.env);
const sessionId = c.get('sessionId');
const now = new Date();
let history: any[] = [];
if (dbContext.type === 'd1') {
history = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.userSessionId, sessionId))
.orderBy(desc(dbContext.table.createdAt))
.limit(5);
} else {
history = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.userSessionId, sessionId))
.orderBy(desc(dbContext.table.createdAt))
.limit(5);
}
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>素嗒会议转发</title>
<style>${GLOBAL_STYLES}</style>
<script>
function copyLinkText(event, url, btnId) {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(url).then(() => {
const btn = document.getElementById(btnId);
const orig = btn.innerText;
btn.innerText = '已复制';
setTimeout(() => { btn.innerText = orig; }, 2000);
});
}
function showQrModal(event, slug, fullUrl) {
event.preventDefault();
event.stopPropagation();
const modal = document.getElementById('qr-modal');
const title = document.getElementById('qr-modal-title');
const img = document.getElementById('qr-modal-img');
title.innerText = slug;
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(fullUrl);
modal.style.display = 'flex';
}
function closeQrModal() {
document.getElementById('qr-modal').style.display = 'none';
}
</script>
</head>
<body>
<div class="glow-bg">
<div class="glow-circle-1"></div>
<div class="glow-circle-2"></div>
</div>
<!-- 分享二维码弹窗 -->
<div id="qr-modal" class="modal-backdrop" onclick="closeQrModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3 id="qr-modal-title" style="font-size: 1.1rem; color: #0f172a;">会议二维码</h3>
<p style="color: #64748b; font-size: 0.85rem; margin-top: 0.25rem;">扫码直接进入腾讯会议转接</p>
<img id="qr-modal-img" class="qr-code-img" src="" alt="二维码" />
<button class="btn-secondary" style="width: 100%; margin-top: 0.5rem;" onclick="closeQrModal()">关闭</button>
</div>
</div>
<div class="container">
<header class="header">
<h1 class="title">会议靓号转发</h1>
<p class="subtitle">一键粘贴会议,自动分配靓号短链,直达入会</p>
</header>
<main>
<div class="card">
<form action="/create" method="POST" class="input-group">
<div class="textarea-wrapper">
<textarea
name="text"
class="meeting-textarea"
placeholder="在此粘贴包含腾讯会议号的文本或链接,例如: rilay 邀请您参加腾讯会议 会议主题:项目讨论会 链接:https://meeting.tencent.com/dm/KbRZ4JuHSC4B # 腾讯会议:739-646-719"
required
></textarea>
</div>
<button type="submit" class="btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
立即生成靓号转接链接
</button>
</form>
</div>
${
history.length > 0
? `
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: #1e293b;">您的近期生成历史</h3>
<div class="history-list">
${history
.map((item: any, idx: number) => {
const host = c.req.header('host') || 'our-service-domain.com';
const fullUrl = `https://${host}/p/${item.slug}`;
const btnId = `copy-btn-${idx}`;
return `
<a href="/p/${item.slug}" class="history-item">
<div style="display: flex; align-items: center; gap: 0.75rem; overflow: hidden; flex: 1; margin-right: 0.5rem;">
<span class="history-slug">${item.slug}</span>
<span style="color: #64748b; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${
escapeHtml(item.topic || item.meetingId || item.dmCode || '会议')
}</span>
</div>
<div class="action-btn-group">
<button id="${btnId}" class="btn-sm" onclick="copyLinkText(event, '${fullUrl}', '${btnId}')">复制</button>
<button class="btn-sm" onclick="showQrModal(event, '${item.slug}', '${fullUrl}')">分享</button>
</div>
</a>
`;
})
.join('')}
</div>
</div>
`
: ''
}
</main>
<footer>
<p>© ${new Date().getFullYear()} 素嗒不吃素</p>
</footer>
</div>
</body>
</html>
`;
return c.html(html);
});
// 2. 创建或提取靓号逻辑
app.post('/create', async (c) => {
const body = await c.req.parseBody();
const rawText = (body['text'] as string) || '';
if (!rawText.trim()) {
return c.redirect('/');
}
// 输入长度限制
if (rawText.length > MAX_INPUT_LENGTH) {
return c.text('Input too long (max 5000 chars)', 400);
}
const parsed = parseMeetingText(rawText);
const dbContext = getDb(c.env);
const sessionId = c.get('sessionId');
const now = new Date();
let existing: any[] = [];
if (dbContext.type === 'd1') {
existing = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.dedupHash, parsed.dedupHash))
.limit(1);
} else {
existing = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.dedupHash, parsed.dedupHash))
.limit(1);
}
if (existing.length > 0) {
return c.redirect(`/p/${existing[0].slug}`);
}
// 生成新的靓号
const newSlug = await generateUniqueNiceSlug(async (slugCandidate) => {
let res: any[] = [];
if (dbContext.type === 'd1') {
res = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.slug, slugCandidate))
.limit(1);
} else {
res = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.slug, slugCandidate))
.limit(1);
}
return res.length > 0;
});
const createdAt = new Date();
const expiresAt = PERMANENT_EXPIRES_AT; // 永久有效
const newRecord = {
id: 'm_' + Math.random().toString(36).substring(2) + Date.now().toString(36),
slug: newSlug,
meetingId: parsed.meetingId || null,
dmCode: parsed.dmCode || null,
dedupHash: parsed.dedupHash,
topic: parsed.topic || null,
rawText: parsed.rawText,
userSessionId: sessionId,
createdAt,
expiresAt,
};
if (dbContext.type === 'd1') {
await dbContext.db.insert(dbContext.table).values(newRecord);
} else {
await dbContext.db.insert(dbContext.table).values(newRecord);
}
return c.redirect(`/p/${newSlug}`);
});
// 3. 靓号页面:/p/:slug
app.get('/p/:slug', async (c) => {
const slug = c.req.param('slug');
const dbContext = getDb(c.env);
let result: any[] = [];
if (dbContext.type === 'd1') {
result = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.slug, slug))
.limit(1);
} else {
result = await dbContext.db
.select()
.from(dbContext.table)
.where(eq(dbContext.table.slug, slug))
.limit(1);
}
if (result.length === 0) {
return c.html(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>靓号链接已失效 - Meeting Forward</title>
<style>${GLOBAL_STYLES}</style>
</head>
<body>
<div class="container" style="text-align: center; padding-top: 5rem;">
<h1 style="font-size: 2rem; margin-bottom: 1rem;">404 - 靓号链接不存在或已过期</h1>
<p style="color: var(--text-muted); margin-bottom: 2rem;">靓号链接不存在或已被删除。</p>
<a href="/" class="btn-primary" style="display: inline-flex; width: auto;">返回首页重新粘贴生成</a>
</div>
</body>
</html>
`, 404);
}
const item = result[0];
const host = c.req.header('host') || 'our-service-domain.com';
const fullUrl = `https://${host}/p/${item.slug}`;
// 唤起逻辑(meetingId 已是纯数字,如 739646719)
const meetingId = item.meetingId || '';
const hasDmCode = !!item.dmCode;
const dmJoinUrl = item.dmCode ? `https://meeting.tencent.com/dm/${item.dmCode}` : '';
// PC 桌面端正确 Scheme: wemeet://page/inmeeting?meeting_code=
const wemeetDesktop = meetingId ? `wemeet://page/inmeeting?meeting_code=${meetingId}` : '';
// 移动端正确 Scheme: tencentmeeting://joinMeeting?meetingId=
const wemeetMobile = meetingId ? `tencentmeeting://joinMeeting?meetingId=${meetingId}` : '';
const webFallbackUrl = item.dmCode
? `https://meeting.tencent.com/dm/${item.dmCode}`
: meetingId
? `https://meeting.tencent.com/p/${meetingId}`
: `https://meeting.tencent.com`;
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>正在唤起腾讯会议 - /p/${item.slug}</title>
<style>${GLOBAL_STYLES}</style>
<script>
const hasDmCode = ${hasDmCode};
const dmJoinUrl = "${dmJoinUrl}";
const wemeetDesktop = "${wemeetDesktop}";
const wemeetMobile = "${wemeetMobile}";
const fallbackUrl = "${webFallbackUrl}";
let countdown = 30;
function isWeChat() {
return /MicroMessenger/i.test(navigator.userAgent);
}
function isMobile() {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
function triggerJoin() {
if (isWeChat()) {
document.getElementById('wechat-overlay').style.display = 'flex';
return;
}
// 直接使用正确的平台专属 wemeet:// 协议唤起(弹出 Open Link 对话框)
if (isMobile() && wemeetMobile) {
// 移动端:tencentmeeting://joinMeeting?meetingId=
window.location.href = wemeetMobile;
} else if (wemeetDesktop) {
// 桌面端:wemeet://page/inmeeting?meeting_code=
window.location.href = wemeetDesktop;
} else if (fallbackUrl) {
// 只有 dm 码、没有会议号时降级直接跳腾讯官网
window.location.href = fallbackUrl;
}
}
function startCountdown() {
const timerElem = document.getElementById('timer-seconds');
const interval = setInterval(() => {
countdown--;
if (timerElem) {
timerElem.innerText = countdown;
}
if (countdown <= 0) {
clearInterval(interval);
// 倒计时 20 秒结束后,无论用户是否点击取消或没有 APP,均自动跳转网页版
window.location.href = fallbackUrl;
}
}, 1000);
}
function copyLink() {
navigator.clipboard.writeText("${fullUrl}").then(() => {
const btn = document.getElementById('copy-btn');
btn.innerText = '已复制!';
setTimeout(() => { btn.innerText = '复制链接'; }, 2000);
});
}
window.onload = function() {
// 1. 尝试自动唤起 APP
triggerJoin();
// 2. 启动 20 秒可见倒计时
startCountdown();
};
</script>
</head>
<body>
<div class="glow-bg">
<div class="glow-circle-1"></div>
<div class="glow-circle-2"></div>
</div>
<!-- 微信提示蒙层 -->
<div id="wechat-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; backdrop-filter: blur(10px); color: #fff; flex-direction: column; align-items: flex-end; padding: 2rem 1.5rem;">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">↗ 点击右上角菜单</div>
<div style="font-size: 1.1rem; color: #9ca3af;">选择“在浏览器打开”以直接唤起腾讯会议 App</div>
</div>
<div class="container">
<header class="header">
${item.topic ? `<h1 class="title" style="color: #0f172a; margin-bottom: 0.5rem;">${escapeHtml(item.topic)}</h1>` : ''}
<div>
<span class="nice-slug-badge">${item.slug}</span>
</div>
</header>
<main>
<div class="card" style="text-align: center;">
<p style="color: var(--text-muted); margin-bottom: 1rem;">专属靓号链接地址:</p>
<div class="copy-box">
<span class="copy-url">${fullUrl}</span>
<button id="copy-btn" class="btn-secondary" onclick="copyLink()">复制链接</button>
</div>
<div style="margin-top: 2rem;">
<button onclick="triggerJoin()" class="btn-primary">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 3h6v6M10 14L21 3M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/></svg>
点击直接打开腾讯会议 App 入会
</button>
</div>
<div class="notice-box">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div style="text-align: left;">
<div>系统正在尝试唤起 <strong>腾讯会议 App</strong>...</div>
<div style="color: #b45309; font-size: 0.875rem; margin-top: 0.25rem;">
将在 <strong id="timer-seconds" style="color: var(--accent-indigo); font-size: 1.1rem; padding: 0 2px;">30</strong> 秒后自动进入<a href="${webFallbackUrl}" style="color: var(--accent-indigo); font-weight: 600; margin-left: 0.25rem;">腾讯会议网页版</a>(即便在弹窗中取消唤起也会按时跳转)。
</div>
</div>
</div>
</div>
<div style="text-align: center;">
<a href="/" style="color: var(--text-muted); text-decoration: none; font-size: 0.95rem;">← 返回首页粘贴新会议</a>
</div>
</main>
</div>
</body>
</html>
`;
return c.html(html);
});
export default app;