码桶

发现社区成员的开源项目

早晚圈 / meeting 公开
main
meeting/src/index.ts
index.ts15.1 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');

// 确保 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="在此粘贴包含腾讯会议号的文本或链接,例如:&#10;rilay 邀请您参加腾讯会议&#10;会议主题:项目讨论会&#10;链接:https://meeting.tencent.com/dm/KbRZ4JuHSC4B&#10;# 腾讯会议: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;">${
                  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('/');
  }

  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;">${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;