first commit
This commit is contained in:
9
src/302ai/__test__.js
Normal file
9
src/302ai/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { get302AiReply } from './index.js'
|
||||
|
||||
// 测试 302 ai api
|
||||
async function testMessage() {
|
||||
const message = await get302AiReply('hello')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
41
src/302ai/index.js
Normal file
41
src/302ai/index.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
dotenv.config()
|
||||
const env = dotenv.config().parsed
|
||||
const key = env._302AI_API_KEY
|
||||
const model = env._302AI_MODEL ? env._302AI_MODEL : 'gpt-4o-mini'
|
||||
|
||||
function setConfig(prompt) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: 'https://api.302.ai/v1/chat/completions',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${key}`,
|
||||
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: JSON.stringify({
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function get302AiReply(prompt) {
|
||||
try {
|
||||
const config = setConfig(prompt)
|
||||
const response = await axios(config)
|
||||
const { choices } = response.data
|
||||
return choices[0].message.content
|
||||
} catch (error) {
|
||||
console.error(error.code)
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
79
src/adapters/lark.js
Normal file
79
src/adapters/lark.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { getLarkRuntimeConfig } from '../config/env.js'
|
||||
import { runCommand, streamCommand } from '../utils/process.js'
|
||||
|
||||
function getLarkBin() {
|
||||
return getLarkRuntimeConfig().bin
|
||||
}
|
||||
|
||||
export async function larkLogin(options = {}) {
|
||||
const args = ['auth', 'login']
|
||||
|
||||
if (options.deviceCode) {
|
||||
args.push('--device-code', options.deviceCode)
|
||||
} else if (options.scope) {
|
||||
args.push('--scope', options.scope)
|
||||
} else {
|
||||
args.push('--domain', options.domain || 'im')
|
||||
}
|
||||
|
||||
if (options.noWait || options.wait === false) {
|
||||
args.push('--no-wait', '--json')
|
||||
}
|
||||
|
||||
return streamCommand(getLarkBin(), args)
|
||||
}
|
||||
|
||||
export async function larkStatus() {
|
||||
return streamCommand(getLarkBin(), ['auth', 'status'])
|
||||
}
|
||||
|
||||
export async function larkSendText(options = {}) {
|
||||
const identity = options.as || getLarkRuntimeConfig().defaultIdentity
|
||||
const args = ['im', '+messages-send', '--as', identity, '--text', options.text || '']
|
||||
|
||||
if (options.chatId) {
|
||||
args.push('--chat-id', options.chatId)
|
||||
} else if (options.userId) {
|
||||
args.push('--user-id', options.userId)
|
||||
} else {
|
||||
throw new Error('larkSendText requires chatId or userId')
|
||||
}
|
||||
|
||||
return streamCommand(getLarkBin(), args)
|
||||
}
|
||||
|
||||
export async function larkListMessages(options = {}) {
|
||||
const identity = options.as || getLarkRuntimeConfig().defaultIdentity
|
||||
const args = ['im', '+chat-messages-list', '--as', identity, '--format', options.format || 'pretty']
|
||||
|
||||
if (options.chatId) {
|
||||
args.push('--chat-id', options.chatId)
|
||||
} else if (options.userId) {
|
||||
args.push('--user-id', options.userId)
|
||||
} else {
|
||||
throw new Error('larkListMessages requires chatId or userId')
|
||||
}
|
||||
|
||||
if (options.start) args.push('--start', options.start)
|
||||
if (options.end) args.push('--end', options.end)
|
||||
if (options.pageSize) args.push('--page-size', String(options.pageSize))
|
||||
|
||||
return streamCommand(getLarkBin(), args)
|
||||
}
|
||||
|
||||
export async function larkSearchMessages(options = {}) {
|
||||
const args = ['im', '+messages-search', '--as', 'user', '--format', options.format || 'pretty']
|
||||
if (options.query) args.push('--query', options.query)
|
||||
if (options.chatId) args.push('--chat-id', options.chatId)
|
||||
if (options.chatType) args.push('--chat-type', options.chatType)
|
||||
if (options.start) args.push('--start', options.start)
|
||||
if (options.end) args.push('--end', options.end)
|
||||
if (options.pageAll) args.push('--page-all')
|
||||
if (options.pageLimit) args.push('--page-limit', String(options.pageLimit))
|
||||
|
||||
return streamCommand(getLarkBin(), args)
|
||||
}
|
||||
|
||||
export async function larkCheckImAuth() {
|
||||
return runCommand(getLarkBin(), ['auth', 'check', '--domain', 'im'], { echo: true })
|
||||
}
|
||||
27
src/adapters/opencli.js
Normal file
27
src/adapters/opencli.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getOpenCliRuntimeConfig } from '../config/env.js'
|
||||
import { splitCommand, streamCommand } from '../utils/process.js'
|
||||
|
||||
export function resolveOpenCliCommand() {
|
||||
const config = getOpenCliRuntimeConfig()
|
||||
const configured = splitCommand(config.bin)
|
||||
if (configured.length) {
|
||||
return {
|
||||
command: configured[0],
|
||||
args: configured.slice(1),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['--yes', config.npmPackage],
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenCli(args = []) {
|
||||
const base = resolveOpenCliCommand()
|
||||
return streamCommand(base.command, [...base.args, ...args])
|
||||
}
|
||||
|
||||
export async function runWxCli(args = []) {
|
||||
return runOpenCli(['wx-cli', ...args])
|
||||
}
|
||||
39
src/adapters/pi.js
Normal file
39
src/adapters/pi.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getPiRuntimeConfig } from '../config/env.js'
|
||||
import { splitCommand, runCommand, streamCommand } from '../utils/process.js'
|
||||
|
||||
export function resolvePiCommand() {
|
||||
const config = getPiRuntimeConfig()
|
||||
const configured = splitCommand(config.bin)
|
||||
if (configured.length) {
|
||||
return {
|
||||
command: configured[0],
|
||||
args: configured.slice(1),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['--yes', config.npmPackage],
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPi(args = []) {
|
||||
const base = resolvePiCommand()
|
||||
return streamCommand(base.command, [...base.args, ...args])
|
||||
}
|
||||
|
||||
export async function askPi(prompt, options = {}) {
|
||||
const base = resolvePiCommand()
|
||||
const config = getPiRuntimeConfig()
|
||||
const agentArgs = splitCommand(options.agentArgs || config.agentArgs)
|
||||
const result = await runCommand(base.command, [...base.args, ...agentArgs, prompt], {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
const detail = result.stderr || result.stdout || `exit code ${result.code}`
|
||||
throw new Error(`pi failed: ${detail}`)
|
||||
}
|
||||
|
||||
return (result.stdout || result.stderr || '').trim()
|
||||
}
|
||||
56
src/analysis/__test__.js
Normal file
56
src/analysis/__test__.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'assert'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { analyzeWechatMessages, buildWechatStats } from './wechatAnalyzer.js'
|
||||
|
||||
const records = [
|
||||
{
|
||||
timestamp: '2026-05-12T08:00:00.000Z',
|
||||
roomName: '研发群',
|
||||
talkerName: 'Alice',
|
||||
talkerAlias: 'Alice',
|
||||
receiverName: '',
|
||||
text: '今天排查登录问题',
|
||||
typeName: 'Text',
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-12T09:00:00.000Z',
|
||||
roomName: '研发群',
|
||||
talkerName: 'Bob',
|
||||
talkerAlias: 'Bob',
|
||||
receiverName: '',
|
||||
text: '我来补日志',
|
||||
typeName: 'Text',
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-12T10:00:00.000Z',
|
||||
roomName: '',
|
||||
talkerName: 'Carol',
|
||||
talkerAlias: 'Carol',
|
||||
receiverName: 'me',
|
||||
text: '周会改到下午',
|
||||
typeName: 'Text',
|
||||
},
|
||||
]
|
||||
|
||||
const stats = buildWechatStats(records)
|
||||
assert.equal(stats.totalMessages, 3)
|
||||
assert.equal(stats.textMessages, 3)
|
||||
assert.equal(stats.topSpeakers[0].name, 'Alice')
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-bot-analysis-'))
|
||||
fs.mkdirSync(tmpDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(tmpDir, 'messages.jsonl'), records.map((record) => JSON.stringify(record)).join('\n'), 'utf8')
|
||||
|
||||
const result = await analyzeWechatMessages({
|
||||
dataDir: tmpDir,
|
||||
room: '研发群',
|
||||
statsOnly: true,
|
||||
})
|
||||
|
||||
assert.equal(result.target, '群聊「研发群」')
|
||||
assert.equal(result.stats.totalMessages, 2)
|
||||
assert.equal(result.analysis, '')
|
||||
|
||||
console.log('analysis tests passed')
|
||||
95
src/analysis/wechatAnalyzer.js
Normal file
95
src/analysis/wechatAnalyzer.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getServe } from '../wechaty/serve.js'
|
||||
import { filterWechatMessages, loadWechatMessages } from '../platforms/wechat/messageStore.js'
|
||||
|
||||
function increment(map, key, step = 1) {
|
||||
if (!key) return
|
||||
map.set(key, (map.get(key) || 0) + step)
|
||||
}
|
||||
|
||||
function topEntries(map, limit = 10) {
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
}
|
||||
|
||||
export function buildWechatStats(records) {
|
||||
const speakers = new Map()
|
||||
const rooms = new Map()
|
||||
const hourly = new Map()
|
||||
let textMessages = 0
|
||||
let totalTextLength = 0
|
||||
|
||||
for (const record of records) {
|
||||
increment(speakers, record.talkerAlias || record.talkerName || 'unknown')
|
||||
increment(rooms, record.roomName || 'private')
|
||||
if (record.timestamp) {
|
||||
increment(hourly, new Date(record.timestamp).getHours().toString().padStart(2, '0'))
|
||||
}
|
||||
if (record.text) {
|
||||
textMessages += 1
|
||||
totalTextLength += record.text.length
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMessages: records.length,
|
||||
textMessages,
|
||||
averageTextLength: textMessages ? Number((totalTextLength / textMessages).toFixed(1)) : 0,
|
||||
topSpeakers: topEntries(speakers),
|
||||
topRooms: topEntries(rooms),
|
||||
hourly: topEntries(hourly, 24).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWechatAnalysisPrompt({ records, stats, target }) {
|
||||
const recentMessages = records
|
||||
.slice(-120)
|
||||
.map((record) => {
|
||||
const speaker = record.talkerAlias || record.talkerName || 'unknown'
|
||||
return `[${record.timestamp}] ${speaker}: ${record.text || `[${record.typeName}]`}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return [
|
||||
'你是一个严谨的中文聊天数据分析助手。',
|
||||
'请基于用户显式提供的本地微信聊天记录做分析,不要编造记录之外的事实。',
|
||||
'输出结构:1. 关键统计;2. 主要话题;3. 互动模式;4. 风险或误读提醒;5. 可执行建议。',
|
||||
`分析对象:${target}`,
|
||||
`基础统计:${JSON.stringify(stats, null, 2)}`,
|
||||
'最近消息样本:',
|
||||
recentMessages || '无文本消息样本。',
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
export async function analyzeWechatMessages(options = {}) {
|
||||
const allRecords = loadWechatMessages({
|
||||
dataDir: options.dataDir,
|
||||
limit: options.limit || 5000,
|
||||
})
|
||||
|
||||
const records = filterWechatMessages(allRecords, {
|
||||
room: options.room,
|
||||
friend: options.friend,
|
||||
query: options.query,
|
||||
start: options.start,
|
||||
end: options.end,
|
||||
})
|
||||
|
||||
const stats = buildWechatStats(records)
|
||||
const target = options.room ? `群聊「${options.room}」` : options.friend ? `好友「${options.friend}」` : '全部本地记录'
|
||||
|
||||
if (options.statsOnly || !records.length) {
|
||||
return {
|
||||
target,
|
||||
stats,
|
||||
analysis: records.length ? '' : '没有匹配到可分析的本地微信消息。',
|
||||
}
|
||||
}
|
||||
|
||||
const getReply = getServe(options.serviceType || 'ChatGPT')
|
||||
const prompt = buildWechatAnalysisPrompt({ records, stats, target })
|
||||
const analysis = await getReply(prompt)
|
||||
|
||||
return { target, stats, analysis }
|
||||
}
|
||||
35
src/chatgpt/index.js
Normal file
35
src/chatgpt/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ChatGPTAPI } from 'chatgpt'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
|
||||
// 定义ChatGPT的配置
|
||||
const config = {
|
||||
markdown: true, // 返回的内容是否需要markdown格式
|
||||
AutoReply: true, // 是否自动回复
|
||||
clearanceToken: env.CHATGPT_CLEARANCE, // ChatGPT的clearance,从cookie取值
|
||||
sessionToken: env.CHATGPT_SESSION_TOKEN, // ChatGPT的sessionToken, 从cookie取值
|
||||
userAgent: env.CHATGPT_USER_AGENT, // ChatGPT的user-agent,从浏览器取值,或者替换为与你的真实浏览器的User-Agent相匹配的值
|
||||
accessToken: env.CHATGPT_ACCESS_TOKEN, // 在用户授权情况下,访问https://chat.openai.com/api/auth/session,获取accesstoken
|
||||
}
|
||||
const api = new ChatGPTAPI(config)
|
||||
|
||||
// 获取 chatGPT 的回复
|
||||
export async function getChatGPTReply(content) {
|
||||
await api.ensureAuth()
|
||||
console.log('🚀🚀🚀 / content', content)
|
||||
// 调用ChatGPT的接口
|
||||
const reply = await api.sendMessage(content, {
|
||||
// "ChatGPT 请求超时!最好开下全局代理。"
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
})
|
||||
console.log('🚀🚀🚀 / reply', reply)
|
||||
return reply
|
||||
|
||||
// // 如果你想要连续语境对话,可以使用下面的代码
|
||||
// const conversation = api.getConversation();
|
||||
// return await conversation.sendMessage(content, {
|
||||
// // "ChatGPT 请求超时!最好开下全局代理。"
|
||||
// timeoutMs: 2 * 60 * 1000,
|
||||
// });
|
||||
}
|
||||
72
src/claude/index.js
Normal file
72
src/claude/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import axios from 'axios'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
dotenv.config()
|
||||
const env = dotenv.config().parsed
|
||||
|
||||
/**
|
||||
* 用于规整env文件中配置的url到标准格式
|
||||
*/
|
||||
function normalizeClaudeBaseUrl(url) {
|
||||
if (!url) {
|
||||
return 'https://api.anthropic.com/v1/messages'
|
||||
}
|
||||
|
||||
let normalizedUrl = url.replace(/\/$/, '')
|
||||
|
||||
if (normalizedUrl.includes('/v1/messages')) {
|
||||
return normalizedUrl
|
||||
}
|
||||
|
||||
if (normalizedUrl.includes('/v1') && !normalizedUrl.includes('/messages')) {
|
||||
return `${normalizedUrl}/messages`
|
||||
}
|
||||
|
||||
if (normalizedUrl.includes('/messages') && !normalizedUrl.includes('/v1')) {
|
||||
return normalizedUrl.replace('/messages', '/v1/messages')
|
||||
}
|
||||
|
||||
return `${normalizedUrl}/v1/messages`
|
||||
}
|
||||
|
||||
const key = env.CLAUDE_API_KEY || ''
|
||||
const model = env.CLAUDE_MODEL ? env.CLAUDE_MODEL : 'claude-3-5-sonnet-latest'
|
||||
const baseUrl = normalizeClaudeBaseUrl(env.CLAUDE_BASE_URL)
|
||||
const system = env.CLAUDE_SYSTEM || ''
|
||||
const apiVersion = env.CLAUDE_API_VERSION || '2023-06-01'
|
||||
|
||||
function claudeConfig(prompt) {
|
||||
const body = {
|
||||
model: model,
|
||||
temperature: 0.4,
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
}
|
||||
if (system !== '') {
|
||||
body.system = system
|
||||
}
|
||||
return {
|
||||
method: 'post',
|
||||
url: baseUrl,
|
||||
headers: {
|
||||
'x-api-key': key,
|
||||
'anthropic-version': apiVersion,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: body,
|
||||
}
|
||||
}
|
||||
export async function getClaudeReply(prompt) {
|
||||
try {
|
||||
const claude = claudeConfig(prompt)
|
||||
const reply = await axios(claude)
|
||||
return reply.data.content[0].text || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
50
src/config/env.js
Normal file
50
src/config/env.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
const dotenvResult = dotenv.config()
|
||||
|
||||
export const env = {
|
||||
...(dotenvResult.parsed || {}),
|
||||
...process.env,
|
||||
}
|
||||
|
||||
export function readCsvEnv(key) {
|
||||
return (env[key] || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function getWechatRuntimeConfig() {
|
||||
return {
|
||||
botName: env.BOT_NAME || '',
|
||||
autoReplyPrefix: env.AUTO_REPLY_PREFIX || '',
|
||||
aliasWhiteList: readCsvEnv('ALIAS_WHITELIST'),
|
||||
roomWhiteList: readCsvEnv('ROOM_WHITELIST'),
|
||||
dataDir: env.WECHAT_DATA_DIR || '.data/wechat',
|
||||
storeMessages: env.WECHAT_STORE_MESSAGES !== 'false',
|
||||
commandPrefix: env.BOT_COMMAND_PREFIX || '/',
|
||||
enableRemoteOpenCli: env.ENABLE_REMOTE_OPENCLI === 'true',
|
||||
}
|
||||
}
|
||||
|
||||
export function getLarkRuntimeConfig() {
|
||||
return {
|
||||
bin: env.LARK_CLI_BIN || 'lark-cli',
|
||||
defaultIdentity: env.LARK_DEFAULT_IDENTITY || 'user',
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenCliRuntimeConfig() {
|
||||
return {
|
||||
bin: env.OPENCLI_BIN || '',
|
||||
npmPackage: env.OPENCLI_NPM_PACKAGE || '@jackwener/opencli',
|
||||
}
|
||||
}
|
||||
|
||||
export function getPiRuntimeConfig() {
|
||||
return {
|
||||
bin: env.PI_BIN || '',
|
||||
npmPackage: env.PI_NPM_PACKAGE || '@earendil-works/pi-coding-agent',
|
||||
agentArgs: env.PI_AGENT_ARGS || '--print --no-session',
|
||||
}
|
||||
}
|
||||
9
src/deepseek-free/__test__.js
Normal file
9
src/deepseek-free/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getDeepSeekFreeReply } from './index.js'
|
||||
|
||||
// 测试 open ai api
|
||||
async function testMessage() {
|
||||
const message = await getDeepSeekFreeReply('hello')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
49
src/deepseek-free/index.js
Normal file
49
src/deepseek-free/index.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'axios'
|
||||
import dotenv from 'dotenv'
|
||||
// 加载环境变量
|
||||
dotenv.config()
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
const token = env.DEEPSEEK_FREE_TOKEN
|
||||
const model = env.DEEPSEEK_FREE_MODEL
|
||||
const url = env.DEEPSEEK_FREE_URL
|
||||
const syscontent = env.DEEPSEEK_FREE_SYSTEM_MESSAGE
|
||||
|
||||
function setConfig(prompt) {
|
||||
return {
|
||||
method: 'post',
|
||||
maxBodyLength: Infinity,
|
||||
// url: 'https://api.deepseek.com/chat/completions',
|
||||
url: url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
data: JSON.stringify({
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: syscontent,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDeepSeekFreeReply(prompt) {
|
||||
try {
|
||||
const config = setConfig(prompt)
|
||||
const response = await axios(config)
|
||||
const { choices } = response.data
|
||||
return choices[0].message.content
|
||||
} catch (error) {
|
||||
console.error(error.code)
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
9
src/deepseek/__test__.js
Normal file
9
src/deepseek/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getDoubaoReply } from './index.js'
|
||||
|
||||
// 测试 open ai api
|
||||
async function testMessage() {
|
||||
const message = await getDoubaoReply('猪可以吃钛合金吗')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
36
src/deepseek/index.js
Normal file
36
src/deepseek/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { remark } from 'remark'
|
||||
import stripMarkdown from 'strip-markdown'
|
||||
import OpenAI from 'openai'
|
||||
import dotenv from 'dotenv'
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.resolve()
|
||||
// 判断是否有 .env 文件, 没有则报错
|
||||
const envPath = path.join(__dirname, '.env')
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.log('❌ 请先根据文档,创建并配置.env文件!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let config = {
|
||||
apiKey: env.DEEPSEEK_API_KEY,
|
||||
}
|
||||
if (env.DEEPSEEK_URL) {
|
||||
config.baseURL = env.DEEPSEEK_URL
|
||||
}
|
||||
const openai = new OpenAI(config)
|
||||
const chosen_model = env.DEEPSEEK_MODEL
|
||||
export async function getDeepseekReply(prompt) {
|
||||
console.log('🚀🚀🚀 / prompt', prompt)
|
||||
const response = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: env.DEEPSEEK_SYSTEM_MESSAGE },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
model: chosen_model,
|
||||
})
|
||||
console.log('🚀🚀🚀 / reply', response.choices[0].message.content)
|
||||
return `${response.choices[0].message.content}`
|
||||
}
|
||||
9
src/dify/__test__.js
Normal file
9
src/dify/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getDifyReply } from './index.js'
|
||||
|
||||
// 测试 dify api
|
||||
async function testMessage() {
|
||||
const message = await getDifyReply('hello')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
39
src/dify/index.js
Normal file
39
src/dify/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios'
|
||||
import dotenv from 'dotenv'
|
||||
// 加载环境变量
|
||||
dotenv.config()
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
const token = env.DIFY_API_KEY
|
||||
const url = env.DIFY_URL
|
||||
const bot_name = env.BOT_NAME
|
||||
function setConfig(prompt) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: `${url}/chat-messages`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
data: JSON.stringify({
|
||||
inputs: {},
|
||||
query: prompt,
|
||||
response_mode: 'blocking',
|
||||
user: bot_name,
|
||||
files: [],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDifyReply(prompt) {
|
||||
try {
|
||||
const config = setConfig(prompt)
|
||||
console.log('🌸🌸🌸 / config: ', config)
|
||||
const response = await axios(config)
|
||||
console.log('🌸🌸🌸 / response: ', response)
|
||||
return response.data.answer
|
||||
} catch (error) {
|
||||
console.error(error.code)
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
12
src/doubao/__test__.js
Normal file
12
src/doubao/__test__.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDoubaoReply } from './index.js'
|
||||
|
||||
// 测试 open ai api
|
||||
async function testMessage() {
|
||||
let message
|
||||
message = await getDoubaoReply('猪可以吃钛合金吗')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
message = await getDoubaoReply('这是哪里?', 'https://ark-project.tos-cn-beijing.ivolces.com/images/view.jpeg')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
53
src/doubao/index.js
Normal file
53
src/doubao/index.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { remark } from 'remark'
|
||||
import stripMarkdown from 'strip-markdown'
|
||||
import OpenAI from 'openai'
|
||||
import dotenv from 'dotenv'
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.resolve()
|
||||
// 判断是否有 .env 文件, 没有则报错
|
||||
const envPath = path.join(__dirname, '.env')
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.log('❌ 请先根据文档,创建并配置.env文件!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let config = {
|
||||
apiKey: env.DOUBAO_API_KEY,
|
||||
baseURL: env.DOUBAO_URL,
|
||||
}
|
||||
const openai = new OpenAI(config)
|
||||
const chosen_model = env.DOUBAO_MODEL
|
||||
export async function getDoubaoReply(prompt, img_url = '') {
|
||||
const only_text = img_url == ''
|
||||
console.log('🚀🚀🚀 / prompt', prompt)
|
||||
let response
|
||||
if (only_text) {
|
||||
response = await openai.chat.completions.create({
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
model: chosen_model,
|
||||
})
|
||||
} else {
|
||||
response = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: img_url,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
model: chosen_model,
|
||||
})
|
||||
}
|
||||
console.log('🚀🚀🚀 / reply', response.choices[0].message.content)
|
||||
return `${response.choices[0].message.content}`
|
||||
}
|
||||
259
src/index.js
Normal file
259
src/index.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Command } from 'commander'
|
||||
import inquirer from 'inquirer'
|
||||
import fs from 'fs'
|
||||
import path, { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { env, getWechatRuntimeConfig } from './config/env.js'
|
||||
import { analyzeWechatMessages } from './analysis/wechatAnalyzer.js'
|
||||
import { larkListMessages, larkLogin, larkSearchMessages, larkSendText, larkStatus } from './adapters/lark.js'
|
||||
import { runOpenCli, runWxCli } from './adapters/opencli.js'
|
||||
import { runPi } from './adapters/pi.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
const { version, name } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8'))
|
||||
|
||||
export const serveList = [
|
||||
{ name: 'ChatGPT', value: 'ChatGPT' },
|
||||
{ name: 'doubao', value: 'doubao' },
|
||||
{ name: 'deepseek', value: 'deepseek' },
|
||||
{ name: 'Kimi', value: 'Kimi' },
|
||||
{ name: 'Xunfei', value: 'Xunfei' },
|
||||
{ name: 'deepseek-free', value: 'deepseek-free' },
|
||||
{ name: '302AI', value: '302AI' },
|
||||
{ name: 'dify', value: 'dify' },
|
||||
{ name: 'ollama', value: 'ollama' },
|
||||
{ name: 'tongyi', value: 'tongyi' },
|
||||
{ name: 'claude', value: 'claude' },
|
||||
{ name: 'pi', value: 'pi' },
|
||||
]
|
||||
|
||||
function getMissingConfig(type) {
|
||||
switch (type) {
|
||||
case 'ChatGPT':
|
||||
return env.OPENAI_API_KEY ? [] : ['OPENAI_API_KEY']
|
||||
case 'doubao':
|
||||
return env.DOUBAO_API_KEY ? [] : ['DOUBAO_API_KEY']
|
||||
case 'deepseek':
|
||||
return env.DEEPSEEK_API_KEY ? [] : ['DEEPSEEK_API_KEY']
|
||||
case 'Kimi':
|
||||
return env.KIMI_API_KEY ? [] : ['KIMI_API_KEY']
|
||||
case 'Xunfei':
|
||||
return env.XUNFEI_APP_ID && env.XUNFEI_API_KEY && env.XUNFEI_API_SECRET ? [] : ['XUNFEI_APP_ID', 'XUNFEI_API_KEY', 'XUNFEI_API_SECRET']
|
||||
case 'deepseek-free':
|
||||
return env.DEEPSEEK_FREE_URL && env.DEEPSEEK_FREE_TOKEN && env.DEEPSEEK_FREE_MODEL
|
||||
? []
|
||||
: ['DEEPSEEK_FREE_URL', 'DEEPSEEK_FREE_TOKEN', 'DEEPSEEK_FREE_MODEL']
|
||||
case '302AI':
|
||||
return env._302AI_API_KEY ? [] : ['_302AI_API_KEY']
|
||||
case 'dify':
|
||||
return env.DIFY_API_KEY && env.DIFY_URL ? [] : ['DIFY_API_KEY', 'DIFY_URL']
|
||||
case 'ollama':
|
||||
return env.OLLAMA_URL && env.OLLAMA_MODEL ? [] : ['OLLAMA_URL', 'OLLAMA_MODEL']
|
||||
case 'tongyi':
|
||||
return env.TONGYI_URL && env.TONGYI_MODEL ? [] : ['TONGYI_URL', 'TONGYI_MODEL']
|
||||
case 'claude':
|
||||
return env.CLAUDE_API_KEY && env.CLAUDE_MODEL ? [] : ['CLAUDE_API_KEY', 'CLAUDE_MODEL']
|
||||
case 'pi':
|
||||
return []
|
||||
default:
|
||||
return ['SERVICE_TYPE']
|
||||
}
|
||||
}
|
||||
|
||||
async function startWechat(type) {
|
||||
const serviceType = type || env.SERVICE_TYPE
|
||||
if (!serveList.find((item) => item.value === serviceType)) {
|
||||
console.log('服务类型错误,目前支持:' + serveList.map((item) => item.value).join(' | '))
|
||||
return
|
||||
}
|
||||
|
||||
const missing = getMissingConfig(serviceType)
|
||||
if (missing.length) {
|
||||
console.log(`请先配置 .env 文件中的 ${missing.join(',')}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('service type:', serviceType)
|
||||
const { startWechatBot } = await import('./platforms/wechat/bot.js')
|
||||
startWechatBot({ serviceType })
|
||||
}
|
||||
|
||||
async function promptAndStart() {
|
||||
if (env.SERVICE_TYPE) {
|
||||
await startWechat(env.SERVICE_TYPE)
|
||||
return
|
||||
}
|
||||
|
||||
const answer = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'serviceType',
|
||||
message: '请先选择服务类型',
|
||||
choices: serveList,
|
||||
},
|
||||
])
|
||||
|
||||
await startWechat(answer.serviceType)
|
||||
}
|
||||
|
||||
function printAnalysisResult(result) {
|
||||
console.log(`分析对象:${result.target}`)
|
||||
console.log(JSON.stringify(result.stats, null, 2))
|
||||
if (result.analysis) {
|
||||
console.log('\n分析结果:')
|
||||
console.log(result.analysis)
|
||||
}
|
||||
}
|
||||
|
||||
const program = new Command(name)
|
||||
program.alias('we').description('一个基于 WeChaty 结合 AI 服务实现的微信机器人。').version(version, '-v, --version, -V')
|
||||
|
||||
program.option('-s, --serve <type>', '跳过交互,直接设置启动的服务类型').action(async () => {
|
||||
const { serve } = program.opts()
|
||||
if (serve) {
|
||||
await startWechat(serve)
|
||||
return
|
||||
}
|
||||
await promptAndStart()
|
||||
})
|
||||
|
||||
program
|
||||
.command('start')
|
||||
.description('启动微信 IM,终端展示二维码扫码登录')
|
||||
.option('-s, --serve <type>', '跳过交互,直接设置启动的服务类型')
|
||||
.action(async (options) => {
|
||||
if (options.serve) {
|
||||
await startWechat(options.serve)
|
||||
return
|
||||
}
|
||||
await promptAndStart()
|
||||
})
|
||||
|
||||
program
|
||||
.command('agent')
|
||||
.description('启动外部 IM 通道,并使用指定 agent 处理消息')
|
||||
.option('--im <channel>', '外部通信渠道:wechat', 'wechat')
|
||||
.option('--agent <agent>', '消息处理 agent:pi 或其他 serve 类型', 'pi')
|
||||
.action(async (options) => {
|
||||
if (options.im !== 'wechat') {
|
||||
console.log('当前 agent 命令只支持 --im wechat。飞书可先使用 wb lark login/send/messages/search。')
|
||||
return
|
||||
}
|
||||
|
||||
await startWechat(options.agent)
|
||||
})
|
||||
|
||||
program
|
||||
.command('analyze')
|
||||
.description('分析本地捕获的微信聊天记录')
|
||||
.option('--room <name>', '按群聊名称分析')
|
||||
.option('--friend <name>', '按好友昵称或备注分析')
|
||||
.option('--query <keyword>', '只分析包含关键词的消息')
|
||||
.option('--start <iso>', '开始时间 ISO 8601')
|
||||
.option('--end <iso>', '结束时间 ISO 8601')
|
||||
.option('--limit <number>', '最多读取最近 N 条本地消息', '5000')
|
||||
.option('-s, --serve <type>', '用于生成深度分析的 AI 服务', env.SERVICE_TYPE || 'ChatGPT')
|
||||
.option('--stats-only', '只输出统计,不调用 AI 服务')
|
||||
.action(async (options) => {
|
||||
const config = getWechatRuntimeConfig()
|
||||
const result = await analyzeWechatMessages({
|
||||
...options,
|
||||
serviceType: options.serve,
|
||||
dataDir: config.dataDir,
|
||||
limit: Number(options.limit),
|
||||
})
|
||||
printAnalysisResult(result)
|
||||
})
|
||||
|
||||
const lark = program.command('lark').description('飞书 IM 登录、发消息和读取消息')
|
||||
|
||||
lark
|
||||
.command('login')
|
||||
.description('使用 lark-cli device flow 登录飞书 IM')
|
||||
.option('--scope <scope>', '指定 scope,例:im:message:readonly')
|
||||
.option('--domain <domain>', '按 domain 授权', 'im')
|
||||
.option('--no-wait', '只生成授权链接/扫码信息,不阻塞等待授权完成')
|
||||
.option('--device-code <code>', '继续完成上一次 --no-wait 返回的 device_code')
|
||||
.action(async (options) => {
|
||||
await larkLogin(options)
|
||||
})
|
||||
|
||||
lark
|
||||
.command('status')
|
||||
.description('查看当前飞书授权状态')
|
||||
.action(async () => {
|
||||
await larkStatus()
|
||||
})
|
||||
|
||||
lark
|
||||
.command('send')
|
||||
.description('发送飞书 IM 文本消息')
|
||||
.option('--as <identity>', 'user 或 bot', 'user')
|
||||
.option('--chat-id <chatId>', '群聊 ID,oc_xxx')
|
||||
.option('--user-id <userId>', '用户 open_id,ou_xxx')
|
||||
.requiredOption('--text <text>', '文本内容')
|
||||
.action(async (options) => {
|
||||
await larkSendText(options)
|
||||
})
|
||||
|
||||
lark
|
||||
.command('messages')
|
||||
.description('读取某个飞书群聊或 P2P 会话消息')
|
||||
.option('--as <identity>', 'user 或 bot', 'user')
|
||||
.option('--chat-id <chatId>', '群聊 ID,oc_xxx')
|
||||
.option('--user-id <userId>', '用户 open_id,ou_xxx')
|
||||
.option('--start <iso>', '开始时间 ISO 8601')
|
||||
.option('--end <iso>', '结束时间 ISO 8601')
|
||||
.option('--page-size <number>', '分页大小', '50')
|
||||
.option('--format <format>', 'json | pretty | table | ndjson | csv', 'pretty')
|
||||
.action(async (options) => {
|
||||
await larkListMessages(options)
|
||||
})
|
||||
|
||||
lark
|
||||
.command('search')
|
||||
.description('搜索飞书 IM 消息')
|
||||
.option('--query <keyword>', '搜索关键词')
|
||||
.option('--chat-id <chatId>', '限制群聊 ID')
|
||||
.option('--chat-type <type>', 'group 或 p2p')
|
||||
.option('--start <iso>', '开始时间 ISO 8601')
|
||||
.option('--end <iso>', '结束时间 ISO 8601')
|
||||
.option('--page-all', '自动翻页')
|
||||
.option('--page-limit <number>', '最多翻页数', '20')
|
||||
.option('--format <format>', 'json | pretty | table | ndjson | csv', 'pretty')
|
||||
.action(async (options) => {
|
||||
await larkSearchMessages(options)
|
||||
})
|
||||
|
||||
program
|
||||
.command('opencli')
|
||||
.description('透传调用 OpenCLI,用于本地微信、朋友圈或其他本机工具')
|
||||
.allowUnknownOption(true)
|
||||
.argument('[args...]')
|
||||
.action(async (args) => {
|
||||
await runOpenCli(args)
|
||||
})
|
||||
|
||||
program
|
||||
.command('wx')
|
||||
.description('通过 OpenCLI wx-cli 访问本地微信聊天、联系人、群成员和朋友圈缓存')
|
||||
.allowUnknownOption(true)
|
||||
.argument('[args...]')
|
||||
.action(async (args) => {
|
||||
await runWxCli(args)
|
||||
})
|
||||
|
||||
program
|
||||
.command('pi')
|
||||
.description('透传调用 Pi coding agent')
|
||||
.allowUnknownOption(true)
|
||||
.argument('[args...]')
|
||||
.action(async (args) => {
|
||||
await runPi(args)
|
||||
})
|
||||
|
||||
program.parseAsync().catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
8
src/kimi/__test__.js
Normal file
8
src/kimi/__test__.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { getKimiReply } from './index.js'
|
||||
|
||||
// 测试 open ai api
|
||||
async function test() {
|
||||
const message = await getKimiReply('你好!')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
test()
|
||||
94
src/kimi/index.js
Normal file
94
src/kimi/index.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import axios from 'axios'
|
||||
import dotenv from 'dotenv'
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
|
||||
const domain = 'https://api.moonshot.cn'
|
||||
const server = {
|
||||
chat: `${domain}/v1/chat/completions`,
|
||||
models: `${domain}/v1/models`,
|
||||
files: `${domain}/v1/files`,
|
||||
token: `${domain}/v1/tokenizers/estimate-token-count`,
|
||||
// 这块还可以实现上传文件让 kimi 读取并交互等操作
|
||||
// 具体参考文档: https://platform.moonshot.cn/docs/api-reference#api-%E8%AF%B4%E6%98%8E
|
||||
// 由于我近期非常忙碌,这块欢迎感兴趣的同学提 PR ,我会很快合并
|
||||
}
|
||||
|
||||
const configuration = {
|
||||
// 参数详情请参考 https://platform.moonshot.cn/docs/api-reference#%E5%AD%97%E6%AE%B5%E8%AF%B4%E6%98%8E
|
||||
/*
|
||||
Model ID, 可以通过 List Models 获取
|
||||
目前可选 moonshot-v1-8k | moonshot-v1-32k | moonshot-v1-128k
|
||||
*/
|
||||
model: 'moonshot-v1-8k',
|
||||
/*
|
||||
使用什么采样温度,介于 0 和 1 之间。较高的值(如 0.7)将使输出更加随机,而较低的值(如 0.2)将使其更加集中和确定性。
|
||||
如果设置,值域须为 [0, 1] 我们推荐 0.3,以达到较合适的效果。
|
||||
*/
|
||||
temperature: 0.3,
|
||||
/*
|
||||
聊天完成时生成的最大 token 数。如果到生成了最大 token 数个结果仍然没有结束,finish reason 会是 "length", 否则会是 "stop"
|
||||
这个值建议按需给个合理的值,如果不给的话,我们会给一个不错的整数比如 1024。特别要注意的是,这个 max_tokens 是指您期待我们返回的 token 长度,而不是输入 + 输出的总长度。
|
||||
比如对一个 moonshot-v1-8k 模型,它的最大输入 + 输出总长度是 8192,当输入 messages 总长度为 4096 的时候,您最多只能设置为 4096,
|
||||
否则我们服务会返回不合法的输入参数( invalid_request_error ),并拒绝回答。如果您希望获得“输入的精确 token 数”,可以使用下面的“计算 Token” API 使用我们的计算器获得计数。
|
||||
*/
|
||||
max_tokens: 5000,
|
||||
/*
|
||||
是否流式返回, 默认 false, 可选 true
|
||||
*/
|
||||
stream: true,
|
||||
}
|
||||
|
||||
export async function getKimiReply(prompt) {
|
||||
try {
|
||||
const res = await axios.post(
|
||||
server.chat,
|
||||
Object.assign(configuration, {
|
||||
/*
|
||||
包含迄今为止对话的消息列表。
|
||||
要保持对话的上下文,需要将之前的对话历史并入到该数组
|
||||
这是一个结构体的列表,每个元素类似如下:{"role": "user", "content": "你好"} role 只支持 system,user,assistant 其一,content 不得为空
|
||||
*/
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
model: 'moonshot-v1-128k',
|
||||
}),
|
||||
{
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.KIMI_API_KEY}`,
|
||||
},
|
||||
// pass a http proxy agent
|
||||
// proxy: {
|
||||
// host: 'localhost',
|
||||
// port: 7890,
|
||||
// }
|
||||
},
|
||||
)
|
||||
if (!configuration.stream) return res.data.choices[0].message.content
|
||||
|
||||
let result = ''
|
||||
const lines = res.data.split('\n').filter((line) => line.trim() !== '')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const messageObj = line.substring(6)
|
||||
if (messageObj === '[DONE]') break
|
||||
const message = JSON.parse(messageObj)
|
||||
if (message.choices && message.choices[0].delta && message.choices[0].delta.content) {
|
||||
result += message.choices[0].delta.content
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.log('Kimi 错误对应详情可参考官网: https://platform.moonshot.cn/docs/api-reference#%E9%94%99%E8%AF%AF%E8%AF%B4%E6%98%8E')
|
||||
console.log('常见的 401 一般意味着你鉴权失败, 请检查你的 API_KEY 是否正确。')
|
||||
console.log('常见的 429 一般意味着你被限制了请求频次,请求频率过高,或 kimi 服务器过载,可以适当调整请求频率,或者等待一段时间再试。')
|
||||
console.error(error.code)
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
9
src/ollama/__test__.js
Normal file
9
src/ollama/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getDifyReply } from './index.js'
|
||||
|
||||
// 测试 dify api
|
||||
async function testMessage() {
|
||||
const message = await getDifyReply('hello')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
45
src/ollama/index.js
Normal file
45
src/ollama/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios from 'axios'
|
||||
import dotenv from 'dotenv'
|
||||
// 加载环境变量
|
||||
dotenv.config()
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
const url = env.OLLAMA_URL
|
||||
const bot_name = env.BOT_NAME
|
||||
const model_name = env.OLLAMA_MODEL
|
||||
function createRequest(prompt) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
data: JSON.stringify({
|
||||
model: model_name,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: env.OLLAMA_SYSTEM_MESSAGE,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOllamaReply(prompt) {
|
||||
try {
|
||||
console.log('=============== ollama request start ======================')
|
||||
const request = createRequest(prompt)
|
||||
const res = await axios(request)
|
||||
console.log('=============== ollama request finished ======================')
|
||||
return res.data.message.content
|
||||
} catch (error) {
|
||||
console.error(error.code)
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
9
src/openai/__test__.js
Normal file
9
src/openai/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getGptReply } from './index.js'
|
||||
|
||||
// 测试 open ai api
|
||||
async function testMessage() {
|
||||
const message = await getGptReply('hello')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
37
src/openai/index.js
Normal file
37
src/openai/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { remark } from 'remark'
|
||||
import stripMarkdown from 'strip-markdown'
|
||||
import OpenAIApi from 'openai'
|
||||
import dotenv from 'dotenv'
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.resolve()
|
||||
// 判断是否有 .env 文件, 没有则报错
|
||||
const envPath = path.join(__dirname, '.env')
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.log('❌ 请先根据文档,创建并配置.env文件!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let config = {
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
organization: '',
|
||||
}
|
||||
if (env.OPENAI_PROXY_URL) {
|
||||
config.baseURL = env.OPENAI_PROXY_URL
|
||||
}
|
||||
const openai = new OpenAIApi(config)
|
||||
const chosen_model = env.OPENAI_MODEL || 'gpt-4o'
|
||||
export async function getGptReply(prompt) {
|
||||
console.log('🚀🚀🚀 / prompt', prompt)
|
||||
const response = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: env.OPENAI_SYSTEM_MESSAGE },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
model: chosen_model,
|
||||
})
|
||||
console.log('🚀🚀🚀 / reply', response.choices[0].message.content)
|
||||
return `${response.choices[0].message.content}`
|
||||
}
|
||||
13
src/pi/index.js
Normal file
13
src/pi/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { askPi } from '../adapters/pi.js'
|
||||
|
||||
export async function getPiReply(prompt) {
|
||||
const agentPrompt = [
|
||||
'你是当前 wechat-bot 项目的 Pi agent,通过 IM 渠道和外部用户沟通。',
|
||||
'请直接回答用户问题;如果需要访问本地微信聊天、朋友圈、群成员或统计数据,优先建议或使用项目中的 wb wx / wb analyze 能力。',
|
||||
'不要编造本地数据;没有读取到数据时要明确说明。',
|
||||
'',
|
||||
`用户消息:${prompt}`,
|
||||
].join('\n')
|
||||
|
||||
return askPi(agentPrompt)
|
||||
}
|
||||
75
src/platforms/wechat/bot.js
Normal file
75
src/platforms/wechat/bot.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { WechatyBuilder, ScanStatus, log } from 'wechaty'
|
||||
import qrTerminal from 'qrcode-terminal'
|
||||
import { defaultMessage } from '../../wechaty/sendMessage.js'
|
||||
import { captureWechatMessage } from './messageStore.js'
|
||||
import { getWechatRuntimeConfig } from '../../config/env.js'
|
||||
|
||||
function onScan(qrcode, status) {
|
||||
if (status === ScanStatus.Waiting || status === ScanStatus.Timeout) {
|
||||
qrTerminal.generate(qrcode, { small: true })
|
||||
const qrcodeImageUrl = ['https://api.qrserver.com/v1/create-qr-code/?data=', encodeURIComponent(qrcode)].join('')
|
||||
console.log('onScan:', qrcodeImageUrl, ScanStatus[status], status)
|
||||
} else {
|
||||
log.info('onScan: %s(%s)', ScanStatus[status], status)
|
||||
}
|
||||
}
|
||||
|
||||
function onLogin(user) {
|
||||
console.log(`${user} has logged in`)
|
||||
const date = new Date()
|
||||
console.log(`Current time:${date}`)
|
||||
console.log('Automatic robot chat mode has been activated')
|
||||
}
|
||||
|
||||
function onLogout(user) {
|
||||
console.log(`${user} has logged out`)
|
||||
}
|
||||
|
||||
async function onFriendShip(friendship) {
|
||||
const friendShipRe = /chatgpt|chat/
|
||||
if (friendship.type() === 2 && friendShipRe.test(friendship.hello())) {
|
||||
await friendship.accept()
|
||||
}
|
||||
}
|
||||
|
||||
export function createWechatBot(options = {}) {
|
||||
const config = getWechatRuntimeConfig()
|
||||
const chromeBin = process.env.CHROME_BIN ? { endpoint: process.env.CHROME_BIN } : {}
|
||||
const serviceType = options.serviceType || ''
|
||||
|
||||
const bot = WechatyBuilder.build({
|
||||
name: 'WechatEveryDay',
|
||||
puppet: 'wechaty-puppet-wechat4u',
|
||||
puppetOptions: {
|
||||
uos: true,
|
||||
...chromeBin,
|
||||
},
|
||||
})
|
||||
|
||||
bot.on('scan', onScan)
|
||||
bot.on('login', onLogin)
|
||||
bot.on('logout', onLogout)
|
||||
bot.on('friendship', onFriendShip)
|
||||
bot.on('message', async (message) => {
|
||||
await captureWechatMessage(message, bot, {
|
||||
dataDir: config.dataDir,
|
||||
storeMessages: config.storeMessages,
|
||||
})
|
||||
await defaultMessage(message, bot, serviceType)
|
||||
})
|
||||
bot.on('error', (error) => {
|
||||
console.error('bot error handle: ', error)
|
||||
})
|
||||
|
||||
return bot
|
||||
}
|
||||
|
||||
export function startWechatBot(options = {}) {
|
||||
const bot = createWechatBot(options)
|
||||
bot
|
||||
.start()
|
||||
.then(() => console.log('Start to log in wechat...'))
|
||||
.catch((error) => console.error('botStart error: ', error))
|
||||
|
||||
return bot
|
||||
}
|
||||
81
src/platforms/wechat/commandRouter.js
Normal file
81
src/platforms/wechat/commandRouter.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { analyzeWechatMessages } from '../../analysis/wechatAnalyzer.js'
|
||||
import { getWechatRuntimeConfig } from '../../config/env.js'
|
||||
import { runOpenCli } from '../../adapters/opencli.js'
|
||||
|
||||
function stripMention(content, botName) {
|
||||
return content.replace(botName, '').trim()
|
||||
}
|
||||
|
||||
function parseTarget(tokens) {
|
||||
const type = tokens[1]
|
||||
const value = tokens.slice(2).join(' ').trim()
|
||||
|
||||
if (['群', '群聊', 'room', 'group'].includes(type)) {
|
||||
return { room: value }
|
||||
}
|
||||
|
||||
if (['好友', 'friend', 'contact'].includes(type)) {
|
||||
return { friend: value }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function handleWechatCommand(content, context = {}) {
|
||||
const config = getWechatRuntimeConfig()
|
||||
const normalized = stripMention(content, config.botName)
|
||||
|
||||
if (!normalized.startsWith(config.commandPrefix)) {
|
||||
return { handled: false }
|
||||
}
|
||||
|
||||
const commandLine = normalized.slice(config.commandPrefix.length).trim()
|
||||
const tokens = commandLine.split(/\s+/).filter(Boolean)
|
||||
const command = tokens[0]
|
||||
|
||||
if (['分析', 'analyze', '统计', 'stats'].includes(command)) {
|
||||
const statsOnly = ['统计', 'stats'].includes(command)
|
||||
const target = parseTarget(tokens)
|
||||
const result = await analyzeWechatMessages({
|
||||
...target,
|
||||
serviceType: context.serviceType,
|
||||
dataDir: config.dataDir,
|
||||
statsOnly,
|
||||
})
|
||||
|
||||
if (statsOnly || !result.analysis) {
|
||||
return {
|
||||
handled: true,
|
||||
reply: [
|
||||
`${result.target}`,
|
||||
`消息数:${result.stats.totalMessages}`,
|
||||
`文本消息:${result.stats.textMessages}`,
|
||||
`平均长度:${result.stats.averageTextLength}`,
|
||||
`高频发言:${result.stats.topSpeakers.map((item) => `${item.name}(${item.count})`).join(',') || '无'}`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
reply: result.analysis,
|
||||
}
|
||||
}
|
||||
|
||||
if (command === 'opencli') {
|
||||
if (!config.enableRemoteOpenCli) {
|
||||
return {
|
||||
handled: true,
|
||||
reply: '远程 OpenCLI 执行未开启。需要在 .env 中显式设置 ENABLE_REMOTE_OPENCLI=true。',
|
||||
}
|
||||
}
|
||||
|
||||
await runOpenCli(tokens.slice(1))
|
||||
return {
|
||||
handled: true,
|
||||
reply: 'OpenCLI 命令已执行,结果请看本机控制台。',
|
||||
}
|
||||
}
|
||||
|
||||
return { handled: false }
|
||||
}
|
||||
96
src/platforms/wechat/messageStore.js
Normal file
96
src/platforms/wechat/messageStore.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const MESSAGE_FILE = 'messages.jsonl'
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
export function getMessageStorePath(dataDir = '.data/wechat') {
|
||||
return path.resolve(process.cwd(), dataDir, MESSAGE_FILE)
|
||||
}
|
||||
|
||||
export async function captureWechatMessage(message, bot, options = {}) {
|
||||
const dataDir = options.dataDir || '.data/wechat'
|
||||
const storeMessages = options.storeMessages !== false
|
||||
if (!storeMessages) return null
|
||||
|
||||
const talker = message.talker()
|
||||
const receiver = message.to()
|
||||
const room = message.room()
|
||||
const isText = message.type() === bot.Message.Type.Text
|
||||
const roomName = room ? await room.topic() : ''
|
||||
const talkerAlias = talker ? await talker.alias() : ''
|
||||
const talkerName = talker ? await talker.name() : ''
|
||||
const receiverName = receiver ? await receiver.name() : ''
|
||||
|
||||
const record = {
|
||||
id: message.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: message.type(),
|
||||
typeName: bot.Message.Type[message.type()] || String(message.type()),
|
||||
isText,
|
||||
isRoom: Boolean(room),
|
||||
roomName,
|
||||
talkerName,
|
||||
talkerAlias,
|
||||
receiverName,
|
||||
text: isText ? message.text() : '',
|
||||
self: Boolean(talker?.self?.()),
|
||||
}
|
||||
|
||||
const storePath = getMessageStorePath(dataDir)
|
||||
ensureDir(path.dirname(storePath))
|
||||
fs.appendFileSync(storePath, `${JSON.stringify(record)}\n`, 'utf8')
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
export function loadWechatMessages(options = {}) {
|
||||
const storePath = getMessageStorePath(options.dataDir)
|
||||
if (!fs.existsSync(storePath)) return []
|
||||
|
||||
const lines = fs
|
||||
.readFileSync(storePath, 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const limit = Number(options.limit || 0)
|
||||
const selectedLines = limit > 0 ? lines.slice(-limit) : lines
|
||||
|
||||
return selectedLines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function filterWechatMessages(records, filters = {}) {
|
||||
const startTime = filters.start ? new Date(filters.start).getTime() : null
|
||||
const endTime = filters.end ? new Date(filters.end).getTime() : null
|
||||
const query = filters.query ? filters.query.toLowerCase() : ''
|
||||
|
||||
return records.filter((record) => {
|
||||
if (filters.room && record.roomName !== filters.room) return false
|
||||
if (filters.friend) {
|
||||
const names = [record.talkerName, record.talkerAlias, record.receiverName].filter(Boolean)
|
||||
if (!names.includes(filters.friend)) return false
|
||||
}
|
||||
if (
|
||||
query &&
|
||||
!String(record.text || '')
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
)
|
||||
return false
|
||||
if (startTime && new Date(record.timestamp).getTime() < startTime) return false
|
||||
if (endTime && new Date(record.timestamp).getTime() > endTime) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
42
src/tongyi/index.js
Normal file
42
src/tongyi/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
// 加载环境变量
|
||||
dotenv.config()
|
||||
const url = env.TONGYI_URL
|
||||
const api_key = env.TONGYI_API_KEY
|
||||
const model_name = env.TONGYI_MODEL || 'qwen-plus'
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: api_key,
|
||||
baseURL: url,
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
const __dirname = path.resolve()
|
||||
// 判断是否有 .env 文件, 没有则报错
|
||||
const envPath = path.join(__dirname, '.env')
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.log('❌ 请先根据文档,创建并配置 .env 文件!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export async function getTongyiReply(prompt) {
|
||||
const completion = await openai.chat.completions.create({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt + ' ,用中文回答',
|
||||
},
|
||||
],
|
||||
model: model_name,
|
||||
})
|
||||
|
||||
console.log('🚀🚀🚀 / prompt', prompt)
|
||||
const Content = await completion.choices[0].message.content
|
||||
console.log('🚀🚀🚀 / reply', Content)
|
||||
return `${Content}`
|
||||
}
|
||||
53
src/utils/process.js
Normal file
53
src/utils/process.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
export function splitCommand(command) {
|
||||
if (!command) return []
|
||||
return command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^["']|["']$/g, '')) || []
|
||||
}
|
||||
|
||||
export function runCommand(command, args = [], options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: options.env || process.env,
|
||||
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
if (options.echo) process.stdout.write(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
if (options.echo) process.stderr.write(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => {
|
||||
resolve({ code, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function streamCommand(command, args = [], options = {}) {
|
||||
const result = await runCommand(command, args, {
|
||||
...options,
|
||||
stdio: options.stdio || 'inherit',
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
const error = new Error(`${command} exited with code ${result.code}`)
|
||||
error.result = result
|
||||
throw error
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
136
src/wechaty/sendMessage.js
Normal file
136
src/wechaty/sendMessage.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getServe } from './serve.js'
|
||||
import { getWechatRuntimeConfig } from '../config/env.js'
|
||||
import { handleWechatCommand } from '../platforms/wechat/commandRouter.js'
|
||||
|
||||
/**
|
||||
* 默认消息发送
|
||||
* @param msg
|
||||
* @param bot
|
||||
* @param ServiceType 服务类型 'GPT' | 'Kimi'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function defaultMessage(msg, bot, ServiceType = 'GPT') {
|
||||
const { botName, autoReplyPrefix, aliasWhiteList, roomWhiteList, commandPrefix } = getWechatRuntimeConfig()
|
||||
console.log({ botName, autoReplyPrefix, aliasWhiteList, roomWhiteList, commandPrefix })
|
||||
const getReply = getServe(ServiceType)
|
||||
const contact = msg.talker() // 发消息人
|
||||
const receiver = msg.to() // 消息接收人
|
||||
const content = msg.text() // 消息内容
|
||||
const room = msg.room() // 是否是群消息
|
||||
const roomName = (await room?.topic()) || null // 群名称
|
||||
const alias = (await contact.alias()) || (await contact.name()) // 发消息人昵称
|
||||
const remarkName = await contact.alias() // 备注名称
|
||||
const name = await contact.name() // 微信名称
|
||||
const isText = msg.type() === bot.Message.Type.Text // 消息类型是否为文本
|
||||
const isRoom = roomWhiteList.includes(roomName) && content.includes(`${botName}`) // 是否在群聊白名单内并且艾特了机器人
|
||||
const isAlias = aliasWhiteList.includes(remarkName) || aliasWhiteList.includes(name) // 发消息的人是否在联系人白名单内
|
||||
const isBotSelf = botName === `@${remarkName}` || botName === `@${name}` // 是否是机器人自己
|
||||
const isBotSelfDebug = content.trimStart().startsWith('你是谁') // 是否是机器人自己的调试消息
|
||||
const isAuthorizedCommand = (room && isRoom) || (!room && isAlias)
|
||||
// TODO 你们可以根据自己的需求修改这里的逻辑
|
||||
if ((isBotSelf && !isBotSelfDebug) || !isText) return // 如果是机器人自己发送的消息或者消息类型不是文本则不处理
|
||||
try {
|
||||
if (content.replace(`${botName}`, '').trimStart().startsWith(commandPrefix)) {
|
||||
if (!isAuthorizedCommand) return
|
||||
const commandResult = await handleWechatCommand(content, {
|
||||
serviceType: ServiceType,
|
||||
roomName,
|
||||
alias,
|
||||
name,
|
||||
})
|
||||
if (commandResult.handled) {
|
||||
if (commandResult.reply) {
|
||||
await (room || contact).say(commandResult.reply)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 区分群聊和私聊
|
||||
// 群聊消息去掉艾特主体后,匹配自动回复前缀
|
||||
if (isRoom && room && content.replace(`${botName}`, '').trimStart().startsWith(`${autoReplyPrefix}`)) {
|
||||
const question = (await msg.mentionText()) || content.replace(`${botName}`, '').replace(`${autoReplyPrefix}`, '') // 去掉艾特的消息主体
|
||||
console.log('🌸🌸🌸 / question: ', question)
|
||||
const response = await getReply(question)
|
||||
await room.say(response)
|
||||
}
|
||||
// 私人聊天,白名单内的直接发送
|
||||
// 私人聊天直接匹配自动回复前缀
|
||||
if (isAlias && !room && content.trimStart().startsWith(`${autoReplyPrefix}`)) {
|
||||
const question = content.replace(`${autoReplyPrefix}`, '')
|
||||
console.log('🌸🌸🌸 / content: ', question)
|
||||
const response = await getReply(question)
|
||||
await contact.say(response)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片消息发送
|
||||
* @param message
|
||||
* @param bot
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function shardingMessage(message, bot) {
|
||||
const talker = message.talker()
|
||||
const isText = message.type() === bot.Message.Type.Text // 消息类型是否为文本
|
||||
if (talker.self() || message.type() > 10 || (talker.name() === '微信团队' && isText)) {
|
||||
return
|
||||
}
|
||||
const text = message.text()
|
||||
const room = message.room()
|
||||
if (!room) {
|
||||
console.log(`Chat GPT Enabled User: ${talker.name()}`)
|
||||
const response = await getChatGPTReply(text)
|
||||
await trySay(talker, response)
|
||||
return
|
||||
}
|
||||
let realText = splitMessage(text)
|
||||
// 如果是群聊但不是指定艾特人那么就不进行发送消息
|
||||
if (text.indexOf(`${botName}`) === -1) {
|
||||
return
|
||||
}
|
||||
realText = text.replace(`${botName}`, '')
|
||||
const topic = await room.topic()
|
||||
const response = await getChatGPTReply(realText)
|
||||
const result = `${realText}\n ---------------- \n ${response}`
|
||||
await trySay(room, result)
|
||||
}
|
||||
|
||||
// 分片长度
|
||||
const SINGLE_MESSAGE_MAX_SIZE = 500
|
||||
|
||||
/**
|
||||
* 发送
|
||||
* @param talker 发送哪个 room为群聊类 text为单人
|
||||
* @param msg
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function trySay(talker, msg) {
|
||||
const messages = []
|
||||
let message = msg
|
||||
while (message.length > SINGLE_MESSAGE_MAX_SIZE) {
|
||||
messages.push(message.slice(0, SINGLE_MESSAGE_MAX_SIZE))
|
||||
message = message.slice(SINGLE_MESSAGE_MAX_SIZE)
|
||||
}
|
||||
messages.push(message)
|
||||
for (const msg of messages) {
|
||||
await talker.say(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组消息
|
||||
* @param text
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async function splitMessage(text) {
|
||||
let realText = text
|
||||
const item = text.split('- - - - - - - - - - - - - - -')
|
||||
if (item.length > 1) {
|
||||
realText = item[item.length - 1]
|
||||
}
|
||||
return realText
|
||||
}
|
||||
42
src/wechaty/serve.js
Normal file
42
src/wechaty/serve.js
Normal file
@@ -0,0 +1,42 @@
|
||||
function lazyServe(loader, exportName) {
|
||||
return async (...args) => {
|
||||
const module = await loader()
|
||||
return module[exportName](...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 服务
|
||||
* @param serviceType 服务类型
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function getServe(serviceType) {
|
||||
switch (serviceType) {
|
||||
case 'ChatGPT':
|
||||
return lazyServe(() => import('../openai/index.js'), 'getGptReply')
|
||||
case 'doubao':
|
||||
return lazyServe(() => import('../doubao/index.js'), 'getDoubaoReply')
|
||||
case 'deepseek':
|
||||
return lazyServe(() => import('../deepseek/index.js'), 'getDeepseekReply')
|
||||
case 'Kimi':
|
||||
return lazyServe(() => import('../kimi/index.js'), 'getKimiReply')
|
||||
case 'Xunfei':
|
||||
return lazyServe(() => import('../xunfei/index.js'), 'getXunfeiReply')
|
||||
case 'deepseek-free':
|
||||
return lazyServe(() => import('../deepseek-free/index.js'), 'getDeepSeekFreeReply')
|
||||
case '302AI':
|
||||
return lazyServe(() => import('../302ai/index.js'), 'get302AiReply')
|
||||
case 'dify':
|
||||
return lazyServe(() => import('../dify/index.js'), 'getDifyReply')
|
||||
case 'ollama':
|
||||
return lazyServe(() => import('../ollama/index.js'), 'getOllamaReply')
|
||||
case 'tongyi':
|
||||
return lazyServe(() => import('../tongyi/index.js'), 'getTongyiReply')
|
||||
case 'claude':
|
||||
return lazyServe(() => import('../claude/index.js'), 'getClaudeReply')
|
||||
case 'pi':
|
||||
return lazyServe(() => import('../pi/index.js'), 'getPiReply')
|
||||
default:
|
||||
return lazyServe(() => import('../openai/index.js'), 'getGptReply')
|
||||
}
|
||||
}
|
||||
105
src/wechaty/testMessage.js
Normal file
105
src/wechaty/testMessage.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getGptReply } from '../openai/index.js'
|
||||
import { getKimiReply } from '../kimi/index.js'
|
||||
import { getXunfeiReply } from '../xunfei/index.js'
|
||||
import dotenv from 'dotenv'
|
||||
import inquirer from 'inquirer'
|
||||
import { getDeepSeekFreeReply } from '../deepseek-free/index.js'
|
||||
import { get302AiReply } from '../302ai/index.js'
|
||||
import { getDifyReply } from '../dify/index.js'
|
||||
import { getOllamaReply } from '../ollama/index.js'
|
||||
const env = dotenv.config().parsed // 环境参数
|
||||
|
||||
// 控制启动
|
||||
async function handleRequest(type) {
|
||||
console.log('type: ', type)
|
||||
switch (type) {
|
||||
case 'ChatGPT':
|
||||
if (env.OPENAI_API_KEY) {
|
||||
const message = await getGptReply('hello')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 OPENAI_API_KEY')
|
||||
break
|
||||
case 'Kimi':
|
||||
if (env.KIMI_API_KEY) {
|
||||
const message = await getKimiReply('你好!')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 KIMI_API_KEY')
|
||||
break
|
||||
case 'Xunfei':
|
||||
if (env.XUNFEI_APP_ID && env.XUNFEI_API_KEY && env.XUNFEI_API_SECRET) {
|
||||
const message = await getXunfeiReply('你好!')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 XUNFEI_APP_ID,XUNFEI_API_KEY,XUNFEI_API_SECRET')
|
||||
break
|
||||
case 'deepseek-free':
|
||||
if (env.DEEPSEEK_FREE_URL && env.DEEPSEEK_FREE_TOKEN && env.DEEPSEEK_FREE_MODEL) {
|
||||
const message = await getDeepSeekFreeReply('你好!')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 DEEPSEEK_FREE_URL,DEEPSEEK_FREE_TOKEN,DEEPSEEK_FREE_MODEL')
|
||||
break
|
||||
case 'dify':
|
||||
if (env.DIFY_API_KEY) {
|
||||
const message = await getDifyReply('hello')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 DIFY_API_KEY, DIFY_URL')
|
||||
break
|
||||
case '302AI':
|
||||
if (env._302AI_API_KEY) {
|
||||
const message = await get302AiReply('hello')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 _302AI_API_KEY')
|
||||
break
|
||||
case 'ollama':
|
||||
if (env.OLLAMA_URL) {
|
||||
const message = await getOllamaReply('hello')
|
||||
console.log('🌸🌸🌸 / reply: ', message)
|
||||
return
|
||||
}
|
||||
console.log('❌ 请先配置.env文件中的 OLLAMA_URL')
|
||||
break
|
||||
default:
|
||||
console.log('🚀服务类型错误')
|
||||
}
|
||||
}
|
||||
|
||||
const serveList = [
|
||||
{ name: 'ChatGPT', value: 'ChatGPT' },
|
||||
{ name: 'Kimi', value: 'Kimi' },
|
||||
{ name: 'Xunfei', value: 'Xunfei' },
|
||||
{ name: 'deepseek-free', value: 'deepseek-free' },
|
||||
{ name: '302AI', value: '302AI' },
|
||||
{ name: 'dify', value: 'dify' },
|
||||
// ... 欢迎大家接入更多的服务
|
||||
{ name: 'ollama', value: 'ollama' },
|
||||
]
|
||||
const questions = [
|
||||
{
|
||||
type: 'list',
|
||||
name: 'serviceType', //存储当前问题回答的变量key,
|
||||
message: '请先选择服务类型',
|
||||
choices: serveList,
|
||||
},
|
||||
]
|
||||
function init() {
|
||||
inquirer
|
||||
.prompt(questions)
|
||||
.then((res) => {
|
||||
handleRequest(res.serviceType)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('🚀error:', error)
|
||||
})
|
||||
}
|
||||
init()
|
||||
9
src/xunfei/__test__.js
Normal file
9
src/xunfei/__test__.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getXunfeiReply } from './index.js'
|
||||
|
||||
// 测试 科大讯飞 api
|
||||
async function testMessage() {
|
||||
const message = await getXunfeiReply('秦始皇的儿子是谁?')
|
||||
console.log('🌸🌸🌸 / message: ', message)
|
||||
}
|
||||
|
||||
testMessage()
|
||||
9
src/xunfei/index.js
Normal file
9
src/xunfei/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { xunfeiSendMsg } from './xunfei.js'
|
||||
|
||||
export async function getXunfeiReply(prompt, name) {
|
||||
console.log('🚀🚀🚀 / prompt', prompt)
|
||||
let reply = await xunfeiSendMsg(prompt)
|
||||
|
||||
if (typeof name != 'undefined') reply = `@${name}\n ${reply}`
|
||||
return `${reply}`
|
||||
}
|
||||
133
src/xunfei/xunfei.js
Normal file
133
src/xunfei/xunfei.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import CryptoJS from "crypto-js";
|
||||
import dotenv from "dotenv";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const env = dotenv.config().parsed; // 环境参数
|
||||
|
||||
// APPID,APISecret,APIKey在 https://console.xfyun.cn/services/cbm 获取
|
||||
const appID = env.XUNFEI_APP_ID;
|
||||
const apiKey = env.XUNFEI_API_KEY;
|
||||
const apiSecret = env.XUNFEI_API_SECRET;
|
||||
|
||||
// 地址必须填写,代表着大模型的版本号
|
||||
const modelVersion = env.XUNFEI_MODEL_VERSION || "v4.0"; // 默认值 "v4.0"
|
||||
const httpUrl = new URL(`https://spark-api.xf-yun.com/${modelVersion}/chat`);
|
||||
|
||||
// 判断 prompt 是否存在,如果不存在则使用默认值
|
||||
const prompt = env.XUNFEI_PROMPT || "你是一个专业的智能助手";
|
||||
|
||||
// 动态映射模型版本到 domain 的逻辑
|
||||
const modelVersionMap = {
|
||||
"v1.1": "general",
|
||||
"v2.1": "generalv2",
|
||||
"v3.1": "generalv3",
|
||||
"v3.5": "generalv3.5",
|
||||
"pro-128k": "pro-128k",
|
||||
"max-32k": "max-32k",
|
||||
"v4.0": "4.0Ultra",
|
||||
};
|
||||
|
||||
// 获取模型域名
|
||||
function getModelDomain(httpUrl) {
|
||||
try {
|
||||
const modelPath = httpUrl.pathname.split("/")[1]; // 提取版本号或模型路径
|
||||
return modelVersionMap[modelPath] || "unknown"; // 如果没有匹配,返回 "unknown"
|
||||
} catch (error) {
|
||||
console.error("获取模型域名失败:", error);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
let modelDomain = getModelDomain(httpUrl);
|
||||
|
||||
// 签名生成逻辑(可复用)
|
||||
function generateSignature(httpUrl, apiKey, apiSecret) {
|
||||
const host = "localhost:8080";
|
||||
const date = new Date().toGMTString();
|
||||
const algorithm = "hmac-sha256";
|
||||
const headers = "host date request-line";
|
||||
|
||||
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET ${httpUrl.pathname} HTTP/1.1`;
|
||||
const signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
|
||||
const signature = CryptoJS.enc.Base64.stringify(signatureSha);
|
||||
|
||||
const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;
|
||||
const authorization = btoa(authorizationOrigin);
|
||||
|
||||
const url = `wss://${httpUrl.host}${httpUrl.pathname}?authorization=${authorization}&date=${date}&host=${host}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
// 获取 WebSocket 地址
|
||||
function authenticate() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const url = generateSignature(httpUrl, apiKey, apiSecret);
|
||||
resolve(url);
|
||||
} catch (error) {
|
||||
console.error("认证失败:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 发送消息并处理 WebSocket 逻辑
|
||||
export async function xunfeiSendMsg(inputVal) {
|
||||
// 获取请求地址
|
||||
let myUrl = await authenticate();
|
||||
let socket = new WebSocket(String(myUrl));
|
||||
let total_res = ""; // 清空回答历史
|
||||
|
||||
// 创建一个Promise
|
||||
let messagePromise = new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", () => {
|
||||
const params = {
|
||||
header: {
|
||||
app_id: appID,
|
||||
uid: "fd3f47e4-d",
|
||||
},
|
||||
parameter: {
|
||||
chat: {
|
||||
domain: modelDomain,
|
||||
temperature: 0.8,
|
||||
max_tokens: 1024,
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
message: {
|
||||
text: [
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: inputVal }, // 最新的问题
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
socket.send(JSON.stringify(params));
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(String(event.data));
|
||||
if (data.header.code !== 0) {
|
||||
console.error("Socket 出错:", data.header.code, data.header.message);
|
||||
socket.close();
|
||||
reject("");
|
||||
} else if (data.payload.choices.text && data.header.status === 2) {
|
||||
total_res += data.payload.choices.text[0].content;
|
||||
setTimeout(() => {
|
||||
socket.close();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
resolve(total_res);
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (event) => {
|
||||
console.error("Socket 连接错误:", event);
|
||||
reject("");
|
||||
});
|
||||
});
|
||||
|
||||
return await messagePromise;
|
||||
}
|
||||
Reference in New Issue
Block a user