first commit

This commit is contained in:
2026-06-09 14:50:53 +08:00
commit 6aebc60bfd
55 changed files with 3126 additions and 0 deletions

9
src/302ai/__test__.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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')

View 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
View 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
View 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
View 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',
}
}

View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>', '消息处理 agentpi 或其他 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>', '指定 scopeim: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>', '群聊 IDoc_xxx')
.option('--user-id <userId>', '用户 open_idou_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>', '群聊 IDoc_xxx')
.option('--user-id <userId>', '用户 open_idou_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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
}

View 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 }
}

View 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
View 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
View 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
View 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
View 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
View 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_IDXUNFEI_API_KEYXUNFEI_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_URLDEEPSEEK_FREE_TOKENDEEPSEEK_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
View 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
View 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
View File

@@ -0,0 +1,133 @@
import CryptoJS from "crypto-js";
import dotenv from "dotenv";
import WebSocket from "ws";
const env = dotenv.config().parsed; // 环境参数
// APPIDAPISecretAPIKey在 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;
}