document.ts•11 kB
/**
 * 从URL或ID中提取飞书文档ID
 * 支持多种格式:
 * 1. 标准文档URL: https://xxx.feishu.cn/docs/xxx 或 https://xxx.feishu.cn/docx/xxx
 * 2. API URL: https://open.feishu.cn/open-apis/docx/v1/documents/xxx
 * 3. 直接ID: JcKbdlokYoPIe0xDzJ1cduRXnRf
 * 
 * @param input 文档URL或ID
 * @returns 提取的文档ID或null
 */
export function extractDocumentId(input: string): string | null {
  // 移除首尾空白
  input = input.trim();
  
  // 处理各种URL格式
  const docxMatch = input.match(/\/docx\/([a-zA-Z0-9_-]+)/i);
  const docsMatch = input.match(/\/docs\/([a-zA-Z0-9_-]+)/i);
  const apiMatch = input.match(/\/documents\/([a-zA-Z0-9_-]+)/i);
  const directIdMatch = input.match(/^([a-zA-Z0-9_-]{10,})$/); // 假设ID至少10个字符
  // 按优先级返回匹配结果
  const match = docxMatch || docsMatch || apiMatch || directIdMatch;
  return match ? match[1] : null;
}
/**
 * 从URL或Token中提取Wiki节点ID
 * 支持多种格式:
 * 1. Wiki URL: https://xxx.feishu.cn/wiki/xxx
 * 2. 直接Token: xxx
 * 
 * @param input Wiki URL或Token
 * @returns 提取的Wiki Token或null
 */
export function extractWikiToken(input: string): string | null {
  // 移除首尾空白
  input = input.trim();
  // 处理Wiki URL格式
  const wikiMatch = input.match(/\/wiki\/([a-zA-Z0-9_-]+)/i);
  const directMatch = input.match(/^([a-zA-Z0-9_-]{10,})$/); // 假设Token至少10个字符
  // 提取Token,如果存在查询参数,去掉它们
  let token = wikiMatch ? wikiMatch[1] : (directMatch ? directMatch[1] : null);
  if (token && token.includes('?')) {
    token = token.split('?')[0];
  }
  return token;
}
/**
 * 规范化文档ID
 * 提取输入中的文档ID,如果提取失败则返回原输入
 * 
 * @param input 文档URL或ID
 * @returns 规范化的文档ID
 * @throws 如果无法提取有效ID则抛出错误
 */
export function normalizeDocumentId(input: string): string {
  const id = extractDocumentId(input);
  if (!id) {
    throw new Error(`无法从 "${input}" 提取有效的文档ID`);
  }
  return id;
}
/**
 * 规范化Wiki Token
 * 提取输入中的Wiki Token,如果提取失败则返回原输入
 * 
 * @param input Wiki URL或Token
 * @returns 规范化的Wiki Token
 * @throws 如果无法提取有效Token则抛出错误
 */
export function normalizeWikiToken(input: string): string {
  const token = extractWikiToken(input);
  if (!token) {
    throw new Error(`无法从 "${input}" 提取有效的Wiki Token`);
  }
  return token;
}
/**
 * 根据图片二进制数据检测MIME类型
 * @param buffer 图片二进制数据
 * @returns MIME类型字符串
 */
export function detectMimeType(buffer: Buffer): string {
  // 简单的图片格式检测,根据文件头进行判断
  if (buffer.length < 4) {
    return 'application/octet-stream';
  }
  // JPEG格式
  if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
    return 'image/jpeg';
  }
  // PNG格式
  else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
    return 'image/png';
  }
  // GIF格式
  else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
    return 'image/gif';
  }
  // SVG格式 - 检查字符串前缀
  else if (buffer.length > 5 && buffer.toString('ascii', 0, 5).toLowerCase() === '<?xml' || 
           buffer.toString('ascii', 0, 4).toLowerCase() === '<svg') {
    return 'image/svg+xml';
  }
  // WebP格式
  else if (buffer.length > 12 && 
           buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
           buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
    return 'image/webp';
  }
  // 默认二进制流
  else {
    return 'application/octet-stream';
  }
} 
function formatExpire(seconds: number): string {
  if (!seconds || isNaN(seconds)) return '';
  if (seconds < 0) return `<span style='color:#e53935'>已过期</span> (${seconds}s)`;
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  let str = '';
  if (h) str += h + '小时';
  if (m) str += m + '分';
  if (s || (!h && !m)) str += s + '秒';
  return `${str} (${seconds}s)`;
}
export function renderFeishuAuthResultHtml(data: any): string {
  const isError = data && data.error;
  const now = Math.floor(Date.now() / 1000);
  let expiresIn = data && data.expires_in;
  let refreshExpiresIn = data && (data.refresh_token_expires_in || data.refresh_expires_in);
  if (expiresIn && expiresIn > 1000000000) expiresIn = expiresIn - now;
  if (refreshExpiresIn && refreshExpiresIn > 1000000000) refreshExpiresIn = refreshExpiresIn - now;
  const tokenBlock = data && !isError ? `
    <div class="card">
      <h3>Token 信息</h3>
      <ul class="kv-list">
        <li><b>token_type:</b> <span>${data.token_type || ''}</span></li>
        <li><b>access_token:</b> <span class="foldable" onclick="toggleFold(this)">点击展开/收起</span><pre class="fold scrollable">${data.access_token || ''}</pre></li>
        <li><b>expires_in:</b> <span>${formatExpire(expiresIn)}</span></li>
        <li><b>refresh_token:</b> <span class="foldable" onclick="toggleFold(this)">点击展开/收起</span><pre class="fold scrollable">${data.refresh_token || ''}</pre></li>
        <li><b>refresh_token_expires_in:</b> <span>${formatExpire(refreshExpiresIn)}</span></li>
        <li><b>scope:</b> <pre class="scope">${(data.scope || '').replace(/ /g, '\n')}</pre></li>
      </ul>
      <div class="success-action">
        <span class="success-msg">授权成功,继续完成任务</span>
        <button class="copy-btn" onclick="copySuccessMsg(this)">点击复制到粘贴板</button>
      </div>
    </div>
  ` : '';
  let userBlock = '';
  const userInfo = data && data.userInfo && data.userInfo.data;
  if (userInfo) {
    userBlock = `
      <div class="card user-card">
        <div class="avatar-wrap">
          <img src="${userInfo.avatar_big || userInfo.avatar_thumb || userInfo.avatar_url || ''}" class="avatar" />
        </div>
        <div class="user-info">
          <div class="user-name">${userInfo.name || ''}</div>
          <div class="user-en">${userInfo.en_name || ''}</div>
        </div>
      </div>
    `;
  }
  const errorBlock = isError ? `
    <div class="card error-card">
      <h3>授权失败</h3>
      <div class="error-msg">${escapeHtml(data.error || '')}</div>
      <div class="error-code">错误码: ${data.code || ''}</div>
    </div>
  ` : '';
  return `
    <html>
      <head>
        <title>飞书授权结果</title>
        <meta charset="utf-8"/>
        <meta name="viewport" content="width=device-width,initial-scale=1"/>
        <style>
          body { background: #f7f8fa; font-family: 'Segoe UI', Arial, sans-serif; margin:0; padding:0; }
          .container { max-width: 600px; margin: 40px auto; padding: 16px; }
          .card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px #0001; margin-bottom: 24px; padding: 24px 20px; }
          .user-card { display: flex; align-items: center; gap: 24px; }
          .avatar-wrap { flex-shrink: 0; }
          .avatar { width: 96px; height: 96px; border-radius: 50%; box-shadow: 0 2px 8px #0002; display: block; margin: 0 auto; }
          .user-info { flex: 1; }
          .user-name { font-size: 1.5em; font-weight: bold; margin-bottom: 4px; }
          .user-en { color: #888; margin-bottom: 10px; }
          .kv-list { list-style: none; padding: 0; margin: 0; }
          .kv-list li { margin-bottom: 6px; word-break: break-all; }
          .kv-list b { color: #1976d2; }
          .scope { background: #f0f4f8; border-radius: 4px; padding: 6px; font-size: 0.95em; white-space: pre-line; }
          .foldable { color: #1976d2; cursor: pointer; text-decoration: underline; margin-left: 8px; }
          .fold { display: none; background: #f6f6f6; border-radius: 4px; padding: 6px; margin: 4px 0; font-size: 0.92em; max-width: 100%; overflow-x: auto; word-break: break-all; }
          .scrollable { max-width: 100%; overflow-x: auto; font-family: 'Fira Mono', 'Consolas', 'Menlo', monospace; font-size: 0.93em; }
          .success-action { margin-top: 18px; display: flex; align-items: center; gap: 16px; }
          .success-msg { color: #388e3c; font-weight: bold; }
          .copy-btn { background: #1976d2; color: #fff; border: none; border-radius: 4px; padding: 6px 16px; font-size: 1em; cursor: pointer; transition: background 0.2s; }
          .copy-btn:hover { background: #125ea2; }
          .error-card { border-left: 6px solid #e53935; background: #fff0f0; color: #b71c1c; }
          .error-msg { font-size: 1.1em; margin-bottom: 8px; }
          .error-code { color: #b71c1c; font-size: 0.95em; }
          .raw-block { margin-top: 24px; }
          .raw-toggle { color: #1976d2; cursor: pointer; text-decoration: underline; margin-bottom: 8px; display: inline-block; }
          .raw-pre { display: none; background: #23272e; color: #fff; border-radius: 6px; padding: 12px; font-size: 0.95em; overflow-x: auto; max-width: 100%; }
          @media (max-width: 700px) {
            .container { max-width: 98vw; padding: 4vw; }
            .card { padding: 4vw 3vw; }
            .avatar { width: 64px; height: 64px; }
          }
        </style>
        <script>
          function toggleFold(el) {
            var pre = el.nextElementSibling;
            if (pre.style.display === 'block') {
              pre.style.display = 'none';
            } else {
              pre.style.display = 'block';
            }
          }
          function toggleRaw() {
            var pre = document.getElementById('raw-pre');
            if (pre.style.display === 'block') {
              pre.style.display = 'none';
            } else {
              pre.style.display = 'block';
            }
          }
          function copySuccessMsg(btn) {
            var text = '授权成功,继续完成任务';
            navigator.clipboard.writeText(text).then(function() {
              btn.innerText = '已复制';
              btn.disabled = true;
              setTimeout(function() {
                btn.innerText = '点击复制到粘贴板';
                btn.disabled = false;
              }, 2000);
            });
          }
        </script>
      </head>
      <body>
        <div class="container">
          <h2 style="margin-bottom:24px;">飞书授权结果</h2>
          ${errorBlock}
          ${tokenBlock}
          ${userBlock}
          <div class="card raw-block">
            <span class="raw-toggle" onclick="toggleRaw()">点击展开/收起原始数据</span>
            <pre id="raw-pre" class="raw-pre">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
          </div>
        </div>
      </body>
    </html>
  `;
}
function escapeHtml(str: string) {
  return str.replace(/[&<>"]|'/g, function (c) {
    return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c;
  });
}