grokBotImport.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /** Grok Bot 模板导入:解析 x.ai/bot/<id> 公开页并翻译为夭夭 Bot 的人设草稿。 */
  2. export interface GrokBotMeta {
  3. name: string
  4. description: string
  5. imageUrl?: string
  6. }
  7. export type GrokBotTemplateKind = 'housekeeper' | 'manager' | 'general'
  8. export interface GrokBotDraft {
  9. kind: GrokBotTemplateKind
  10. job?: string
  11. antiJobs?: string[]
  12. actBias?: 'ask_first' | 'ask_key_then_act' | 'act_now'
  13. canManageTeam?: boolean
  14. instructions: string
  15. suggestions: string[]
  16. }
  17. export const GROK_BOT_URL_PATTERN = /^https:\/\/x\.ai\/bot\/([A-Za-z0-9_-]+)/
  18. function decodeEntities(text: string): string {
  19. return text
  20. .replace(/&amp;/g, '&')
  21. .replace(/&lt;/g, '<')
  22. .replace(/&gt;/g, '>')
  23. .replace(/&quot;/g, '"')
  24. .replace(/&#39;|&apos;/g, "'")
  25. .replace(/&#x27;/g, "'")
  26. .trim()
  27. }
  28. function metaContent(html: string, keys: string[]): string | undefined {
  29. for (const key of keys) {
  30. // Attributes on real pages are double-quoted; only exclude the quote
  31. // actually in use so apostrophes inside descriptions survive.
  32. const pattern = new RegExp(
  33. `<meta[^>]+(?:property|name)="${key}"[^>]+content="([^"]*)"|<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${key}"`,
  34. 'i',
  35. )
  36. const match = pattern.exec(html)
  37. const value = match?.[1] ?? match?.[2]
  38. if (value) return decodeEntities(value)
  39. }
  40. return undefined
  41. }
  42. /** Extract the public metadata from a fetched x.ai bot page. */
  43. export function parseGrokBotPage(html: string): GrokBotMeta | null {
  44. const title = metaContent(html, ['og:title', 'twitter:title']) ?? (() => {
  45. const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html)
  46. return match ? decodeEntities(match[1]!.replace(/\s*[-|]\s*Grok.*$/i, '')) : undefined
  47. })()
  48. const description = metaContent(html, ['og:description', 'twitter:description', 'description'])
  49. if (!title) return null
  50. return { name: title.slice(0, 100), description: (description ?? '').slice(0, 1200), imageUrl: metaContent(html, ['og:image', 'twitter:image']) }
  51. }
  52. export class GrokBotImportError extends Error {
  53. code: string
  54. status: number
  55. constructor(message: string, code: string, status = 400) {
  56. super(message)
  57. this.code = code
  58. this.status = status
  59. }
  60. }
  61. /** Optional outbound proxy for x.ai only: HTTPS_PROXY / GROK_BOT_PROXY. */
  62. async function proxyDispatcher(target: string): Promise<unknown> {
  63. const proxy = process.env.GROK_BOT_PROXY ?? process.env.HTTPS_PROXY ?? process.env.https_proxy
  64. if (!proxy || !/^https:\/\//.test(target)) return undefined
  65. try {
  66. const undici = await import('undici')
  67. return new undici.ProxyAgent(proxy)
  68. } catch {
  69. return undefined
  70. }
  71. }
  72. export async function fetchGrokBotPage(url: string, fetchImpl: typeof fetch = fetch, timeoutMs = 12_000): Promise<string> {
  73. if (!GROK_BOT_URL_PATTERN.test(url.trim())) throw new GrokBotImportError('请输入 x.ai/bot/<id> 形式的 Grok Bot 链接', 'invalid_grok_bot_url')
  74. let response: Response
  75. try {
  76. const dispatcher = await proxyDispatcher(url)
  77. response = await fetchImpl(url.trim(), { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15' , accept: 'text/html' }, signal: AbortSignal.timeout(timeoutMs), redirect: 'follow', ...(dispatcher ? { dispatcher } : {}) } as RequestInit)
  78. } catch {
  79. throw new GrokBotImportError('无法访问 x.ai:当前网络可能需要代理(可配置 GROK_BOT_PROXY/HTTPS_PROXY),或改用“粘贴描述导入”', 'grok_bot_fetch_failed', 502)
  80. }
  81. if (!response.ok) throw new GrokBotImportError(`x.ai 返回 ${response.status},请确认链接可公开访问`, 'grok_bot_fetch_failed', 502)
  82. const html = await response.text()
  83. if (!parseGrokBotPage(html)) throw new GrokBotImportError('页面里找不到 Bot 信息,请确认这是 Grok Bot 分享链接', 'grok_bot_page_invalid', 502)
  84. return html
  85. }
  86. export async function fetchAvatarDataUrl(imageUrl: string | undefined, fetchImpl: typeof fetch = fetch, maxBytes = 2_800_000): Promise<string | undefined> {
  87. if (!imageUrl || !/^https:\/\//.test(imageUrl)) return undefined
  88. try {
  89. const dispatcher = await proxyDispatcher(imageUrl)
  90. const response = await fetchImpl(imageUrl, { signal: AbortSignal.timeout(10_000), redirect: 'follow', ...(dispatcher ? { dispatcher } : {}) } as RequestInit)
  91. if (!response.ok) return undefined
  92. const type = (response.headers.get('content-type') ?? '').toLowerCase()
  93. if (!/^image\/(png|jpeg|webp)/.test(type)) return undefined
  94. const buffer = Buffer.from(await response.arrayBuffer())
  95. if (buffer.byteLength > maxBytes) return undefined
  96. return `data:${type.split(';')[0]};base64,${buffer.toString('base64')}`
  97. } catch {
  98. return undefined
  99. }
  100. }
  101. const HOUSEKEEPER_PATTERN = /email|mail|calendar|邮件|日历|日程|invoice|账单|form|表单|rsvp|邀请函/i
  102. const MANAGER_PATTERN = /notion|project|项目|团队|team|task|排期|进度|delegate|委派|分派/i
  103. /** Translate a Grok Bot description into a yaoyao persona draft. */
  104. export function grokBotDraft(meta: GrokBotMeta): GrokBotDraft {
  105. const summary = `${meta.description}`
  106. if (MANAGER_PATTERN.test(summary)) {
  107. return {
  108. kind: 'manager',
  109. job: '管理团队项目:每个项目一份任务计划和一个群聊,任务交给合适的 Bot 并跟进到完成',
  110. antiJobs: ['绝不替专家 Bot 完成具体工作', '不隐瞒风险和延误,坏消息第一时间上报'],
  111. actBias: 'ask_key_then_act',
  112. canManageTeam: true,
  113. instructions: [
  114. `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}`,
  115. '',
  116. '工作方式(夭夭版):',
  117. '1. 每个新项目:创建一份任务计划(写清目标、验收标准、截止时间),并建一个以项目命名的群聊,把相关 Bot 拉进来。',
  118. '2. 任务拆解后写给最合适的成员 Bot,用 @成员名 明确委派;你是管理员,负责分派、跟进、汇总,不亲自做专家工作。',
  119. '3. 每天在群里汇总一次进度:完成、进行中、阻塞,以及需要用户决策的事项。',
  120. '4. 用户是最终决策者:涉及范围、预算、优先级的变化,先问再做。',
  121. ].join('\n'),
  122. suggestions: ['为它准备几个专家 Bot 作为队友(可再从 Grok Bot 模板导入或自建)'],
  123. }
  124. }
  125. if (HOUSEKEEPER_PATTERN.test(summary)) {
  126. return {
  127. kind: 'housekeeper',
  128. job: '盯家庭邮箱和日程,学校的表单、账单和回复邀请不遗漏',
  129. antiJobs: ['绝不直接发送邮件或回复邀请', '绝不替用户提交任何表单', '不擅自改期或取消日程'],
  130. actBias: 'ask_key_then_act',
  131. instructions: [
  132. `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}`,
  133. '',
  134. '工作方式(夭夭版):',
  135. '1. 巡检时用 desktop_shell 在服务器 Mac 上执行 osascript 读取「邮件」App 的未读邮件和「日历」App 今明两天的日程。',
  136. '2. 发现需要处理的事项(表单截止、账单、接送冲突、RSVP)时,在对话里列出清单并给出建议动作。',
  137. '3. 需要回复的邮件:在对话里起草完整回复文本,等用户确认后仍由用户发送。',
  138. '4. 保持每条汇报简短:事项、截止时间、建议,不写长篇解释。',
  139. ].join('\n'),
  140. suggestions: ['在「电脑与定时任务」勾选本机控制并完成文件与命令授权(邮件/日历靠它读取)', '创建每天早晚各一次的定时任务让它自动巡检'],
  141. }
  142. }
  143. return {
  144. kind: 'general',
  145. instructions: `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}\n\n(此 Bot 从 Grok Bot 模板导入。上方为原始描述,可在「角色与规则」中补充一句话职责、反任务清单与行动偏好,让它更贴合你的使用。)`,
  146. suggestions: ['打开「角色与规则」补一句话职责和反任务清单,结构化规范会自动生效'],
  147. }
  148. }