|
|
@@ -0,0 +1,160 @@
|
|
|
+/** Grok Bot 模板导入:解析 x.ai/bot/<id> 公开页并翻译为夭夭 Bot 的人设草稿。 */
|
|
|
+export interface GrokBotMeta {
|
|
|
+ name: string
|
|
|
+ description: string
|
|
|
+ imageUrl?: string
|
|
|
+}
|
|
|
+
|
|
|
+export type GrokBotTemplateKind = 'housekeeper' | 'manager' | 'general'
|
|
|
+
|
|
|
+export interface GrokBotDraft {
|
|
|
+ kind: GrokBotTemplateKind
|
|
|
+ job?: string
|
|
|
+ antiJobs?: string[]
|
|
|
+ actBias?: 'ask_first' | 'ask_key_then_act' | 'act_now'
|
|
|
+ canManageTeam?: boolean
|
|
|
+ instructions: string
|
|
|
+ suggestions: string[]
|
|
|
+}
|
|
|
+
|
|
|
+export const GROK_BOT_URL_PATTERN = /^https:\/\/x\.ai\/bot\/([A-Za-z0-9_-]+)/
|
|
|
+
|
|
|
+function decodeEntities(text: string): string {
|
|
|
+ return text
|
|
|
+ .replace(/&/g, '&')
|
|
|
+ .replace(/</g, '<')
|
|
|
+ .replace(/>/g, '>')
|
|
|
+ .replace(/"/g, '"')
|
|
|
+ .replace(/'|'/g, "'")
|
|
|
+ .replace(/'/g, "'")
|
|
|
+ .trim()
|
|
|
+}
|
|
|
+
|
|
|
+function metaContent(html: string, keys: string[]): string | undefined {
|
|
|
+ for (const key of keys) {
|
|
|
+ // Attributes on real pages are double-quoted; only exclude the quote
|
|
|
+ // actually in use so apostrophes inside descriptions survive.
|
|
|
+ const pattern = new RegExp(
|
|
|
+ `<meta[^>]+(?:property|name)="${key}"[^>]+content="([^"]*)"|<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${key}"`,
|
|
|
+ 'i',
|
|
|
+ )
|
|
|
+ const match = pattern.exec(html)
|
|
|
+ const value = match?.[1] ?? match?.[2]
|
|
|
+ if (value) return decodeEntities(value)
|
|
|
+ }
|
|
|
+ return undefined
|
|
|
+}
|
|
|
+
|
|
|
+/** Extract the public metadata from a fetched x.ai bot page. */
|
|
|
+export function parseGrokBotPage(html: string): GrokBotMeta | null {
|
|
|
+ const title = metaContent(html, ['og:title', 'twitter:title']) ?? (() => {
|
|
|
+ const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html)
|
|
|
+ return match ? decodeEntities(match[1]!.replace(/\s*[-|]\s*Grok.*$/i, '')) : undefined
|
|
|
+ })()
|
|
|
+ const description = metaContent(html, ['og:description', 'twitter:description', 'description'])
|
|
|
+ if (!title) return null
|
|
|
+ return { name: title.slice(0, 100), description: (description ?? '').slice(0, 1200), imageUrl: metaContent(html, ['og:image', 'twitter:image']) }
|
|
|
+}
|
|
|
+
|
|
|
+export class GrokBotImportError extends Error {
|
|
|
+ code: string
|
|
|
+ status: number
|
|
|
+ constructor(message: string, code: string, status = 400) {
|
|
|
+ super(message)
|
|
|
+ this.code = code
|
|
|
+ this.status = status
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Optional outbound proxy for x.ai only: HTTPS_PROXY / GROK_BOT_PROXY. */
|
|
|
+async function proxyDispatcher(target: string): Promise<unknown> {
|
|
|
+ const proxy = process.env.GROK_BOT_PROXY ?? process.env.HTTPS_PROXY ?? process.env.https_proxy
|
|
|
+ if (!proxy || !/^https:\/\//.test(target)) return undefined
|
|
|
+ try {
|
|
|
+ const undici = await import('undici')
|
|
|
+ return new undici.ProxyAgent(proxy)
|
|
|
+ } catch {
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export async function fetchGrokBotPage(url: string, fetchImpl: typeof fetch = fetch, timeoutMs = 12_000): Promise<string> {
|
|
|
+ if (!GROK_BOT_URL_PATTERN.test(url.trim())) throw new GrokBotImportError('请输入 x.ai/bot/<id> 形式的 Grok Bot 链接', 'invalid_grok_bot_url')
|
|
|
+ let response: Response
|
|
|
+ try {
|
|
|
+ const dispatcher = await proxyDispatcher(url)
|
|
|
+ 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)
|
|
|
+ } catch {
|
|
|
+ throw new GrokBotImportError('无法访问 x.ai:当前网络可能需要代理(可配置 GROK_BOT_PROXY/HTTPS_PROXY),或改用“粘贴描述导入”', 'grok_bot_fetch_failed', 502)
|
|
|
+ }
|
|
|
+ if (!response.ok) throw new GrokBotImportError(`x.ai 返回 ${response.status},请确认链接可公开访问`, 'grok_bot_fetch_failed', 502)
|
|
|
+ const html = await response.text()
|
|
|
+ if (!parseGrokBotPage(html)) throw new GrokBotImportError('页面里找不到 Bot 信息,请确认这是 Grok Bot 分享链接', 'grok_bot_page_invalid', 502)
|
|
|
+ return html
|
|
|
+}
|
|
|
+
|
|
|
+export async function fetchAvatarDataUrl(imageUrl: string | undefined, fetchImpl: typeof fetch = fetch, maxBytes = 2_800_000): Promise<string | undefined> {
|
|
|
+ if (!imageUrl || !/^https:\/\//.test(imageUrl)) return undefined
|
|
|
+ try {
|
|
|
+ const dispatcher = await proxyDispatcher(imageUrl)
|
|
|
+ const response = await fetchImpl(imageUrl, { signal: AbortSignal.timeout(10_000), redirect: 'follow', ...(dispatcher ? { dispatcher } : {}) } as RequestInit)
|
|
|
+ if (!response.ok) return undefined
|
|
|
+ const type = (response.headers.get('content-type') ?? '').toLowerCase()
|
|
|
+ if (!/^image\/(png|jpeg|webp)/.test(type)) return undefined
|
|
|
+ const buffer = Buffer.from(await response.arrayBuffer())
|
|
|
+ if (buffer.byteLength > maxBytes) return undefined
|
|
|
+ return `data:${type.split(';')[0]};base64,${buffer.toString('base64')}`
|
|
|
+ } catch {
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const HOUSEKEEPER_PATTERN = /email|mail|calendar|邮件|日历|日程|invoice|账单|form|表单|rsvp|邀请函/i
|
|
|
+const MANAGER_PATTERN = /notion|project|项目|团队|team|task|排期|进度|delegate|委派|分派/i
|
|
|
+
|
|
|
+/** Translate a Grok Bot description into a yaoyao persona draft. */
|
|
|
+export function grokBotDraft(meta: GrokBotMeta): GrokBotDraft {
|
|
|
+ const summary = `${meta.description}`
|
|
|
+ if (MANAGER_PATTERN.test(summary)) {
|
|
|
+ return {
|
|
|
+ kind: 'manager',
|
|
|
+ job: '管理团队项目:每个项目一份任务计划和一个群聊,任务交给合适的 Bot 并跟进到完成',
|
|
|
+ antiJobs: ['绝不替专家 Bot 完成具体工作', '不隐瞒风险和延误,坏消息第一时间上报'],
|
|
|
+ actBias: 'ask_key_then_act',
|
|
|
+ canManageTeam: true,
|
|
|
+ instructions: [
|
|
|
+ `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}`,
|
|
|
+ '',
|
|
|
+ '工作方式(夭夭版):',
|
|
|
+ '1. 每个新项目:创建一份任务计划(写清目标、验收标准、截止时间),并建一个以项目命名的群聊,把相关 Bot 拉进来。',
|
|
|
+ '2. 任务拆解后写给最合适的成员 Bot,用 @成员名 明确委派;你是管理员,负责分派、跟进、汇总,不亲自做专家工作。',
|
|
|
+ '3. 每天在群里汇总一次进度:完成、进行中、阻塞,以及需要用户决策的事项。',
|
|
|
+ '4. 用户是最终决策者:涉及范围、预算、优先级的变化,先问再做。',
|
|
|
+ ].join('\n'),
|
|
|
+ suggestions: ['在「权限」页确认已勾选“允许组建团队”', '为它准备几个专家 Bot 作为队友(可再从 Grok Bot 模板导入或自建)'],
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (HOUSEKEEPER_PATTERN.test(summary)) {
|
|
|
+ return {
|
|
|
+ kind: 'housekeeper',
|
|
|
+ job: '盯家庭邮箱和日程,学校的表单、账单和回复邀请不遗漏',
|
|
|
+ antiJobs: ['绝不直接发送邮件或回复邀请', '绝不替用户提交任何表单', '不擅自改期或取消日程'],
|
|
|
+ actBias: 'ask_key_then_act',
|
|
|
+ instructions: [
|
|
|
+ `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}`,
|
|
|
+ '',
|
|
|
+ '工作方式(夭夭版):',
|
|
|
+ '1. 巡检时用 desktop_shell 在服务器 Mac 上执行 osascript 读取「邮件」App 的未读邮件和「日历」App 今明两天的日程。',
|
|
|
+ '2. 发现需要处理的事项(表单截止、账单、接送冲突、RSVP)时,在对话里列出清单并给出建议动作。',
|
|
|
+ '3. 需要回复的邮件:在对话里起草完整回复文本,等用户确认后仍由用户发送。',
|
|
|
+ '4. 保持每条汇报简短:事项、截止时间、建议,不写长篇解释。',
|
|
|
+ ].join('\n'),
|
|
|
+ suggestions: ['在「电脑与定时任务」勾选本机控制并完成文件与命令授权(邮件/日历靠它读取)', '创建每天早晚各一次的定时任务让它自动巡检'],
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ kind: 'general',
|
|
|
+ instructions: `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}\n\n(此 Bot 从 Grok Bot 模板导入。上方为原始描述,可在「角色与规则」中补充一句话职责、反任务清单与行动偏好,让它更贴合你的使用。)`,
|
|
|
+ suggestions: ['打开「角色与规则」补一句话职责和反任务清单,结构化规范会自动生效'],
|
|
|
+ }
|
|
|
+}
|