在线剪贴板|文件传输

项目地址:https://github.com/TransparentLC/cloud-clipboard

下载项目

使用 Docker 运行

Docker Hub 上的镜像是由他人打包的,仅为方便使用而在这里给出,版本可能会滞后于 repo 内的源代码。

从 Docker Hub 拉取

如果你在使用时遇到了问题,请先确认这个问题在 repo 内的最新的源代码中是否仍然存在。

lthero1/lthero-onlineclip 是本人稍微修改后并打包的,限制容量1GB,无密码,支持Markdown预览,支持多文件同时上传,上传速度快

chenqiyux/lan-clip:latest 是原Readme中的,版本落后,不支持多文件同时上传

1
2
docker pull lthero1/lthero-onlineclip:latest
docker container run -d -p 9501:9501 lthero1/lthero-onlineclip

自己打包

先下载项目git clone https://github.com/TransparentLC/cloud-clipboard.git,随后自己打包

1
2
docker image build -t myclip .
docker container run -d -p 9501:9501 myclip

配置文件说明

// 开头的部分是注释,并不需要写入配置文件中,否则会导致读取失败。

这个配置文件在server-node/app/config.js直接修改,在server/config.json中修改不一定生效!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"server": {
"host": [],
"port": 9501,
"key": null,
"cert": null,
"forceWss": false,
"history": 30,
"auth": false
},
"text": {
"limit": 40960
},
"file": {
"expire": 864000,
"chunk": 12582912,
"limit": 1073741824
}
}

HTTPS 的说明:

如果同时设定了私钥和证书路径,则会使用 HTTPS 协议访问前端界面,未设定则会使用 HTTP 协议。 自用的话,可以使用 mkcert 自行生成证书,并将根证书添加到系统/浏览器的信任列表中。 如果使用了 Nginx 等软件的反向代理,且这些软件已经提供了 HTTPS 连接,则无需在这里设定。

“密码认证”的说明:

如果启用“密码认证”,只有输入正确的密码才能连接到服务端并查看剪贴板内容。 可以将 server.auth 字段设为 true(随机生成六位密码)或字符串(自定义密码)“auth”: “123456” 来启用这个功能,启动服务端后终端会以 Authorization code: ****** 的格式输出当前使用的密码。

在server-node/app/config.js直接修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (!process.argv[2] && !fs.existsSync(defaultConfigPath)) {
console.log(`\x1b[93mConfig file "${defaultConfigPath}" does not exist.\x1b[39m`);
console.log('\x1b[93mA default config file is created and used. Check the descriptions in the repository\'s README.md to modify it.\x1b[39m');
fs.writeFileSync(defaultConfigPath, JSON.stringify({
server: {
host: [],
port: 9501,
key: null,
cert: null,
forceWss: false,
history: 30,
auth: false,
},
text: {
limit: 40960,
},
file: {
expire: 864000,
chunk: 12582912,
limit: 1073741824,
},
}, null, 4));
}

使用HTTP访问

要将运行在Docker容器中的服务通过域名访问,并使用Nginx作为反向代理来转发到宿主机的9501端口,你需要完成几个步骤。这包括设置DNS记录、配置Nginx以及确保网络安全。下面是具体步骤:

步骤 1: 设置DNS记录

确保你的域名 clip.lthero.top 的DNS记录指向托管Nginx的服务器的IP地址。这通常在你的域名注册商处进行设置:

  • A记录:将域名指向IPv4地址。
  • AAAA记录:将域名指向IPv6地址(如果适用)。

步骤 2: 安装并启动Nginx

步骤 1: 更新软件包列表

打开终端,首先使用apt命令更新你的包列表,以确保你安装的是最新版本的Nginx。

1
sudo apt update

步骤 2: 安装Nginx

使用apt安装Nginx。

1
sudo apt install nginx

步骤 3: 配置Nginx

你需要在Nginx中创建一个新的服务器块(server block),或者在已有的默认配置中修改,以设置反向代理。以下是一个基本的Nginx配置示例,将会把所有到 clip.lthero.top 的请求转发到本地的9501端口:

  1. 打开或创建一个新的Nginx配置文件:

    1
    sudo nano /etc/nginx/sites-available/clip.lthero.top
  2. 添加以下配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    server {
    listen 80;
    server_name clip.lthero.top;

    location / {
    proxy_pass http://localhost:9501;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
    }
    }

    这个配置做了以下几点:

    • listen 80; 告诉Nginx监听80端口(HTTP标准端口)。
    • server_name clip.lthero.top; 设置这个块应当响应的域名。
    • proxy_pass http://localhost:9501; 指定所有传入的请求转发到本地的9501端口。
    • proxy_set_header 指令将重要的HTTP头信息转发给后端应用。
  3. 启用配置文件通过创建一个符号链接:

    1
    sudo ln -s /etc/nginx/sites-available/clip.lthero.top /etc/nginx/sites-enabled/
  4. 检查Nginx配置文件是否有语法错误:

    1
    sudo nginx -t
  5. 如果没有错误,重启Nginx以应用配置:

    1
    sudo systemctl restart nginx

步骤 4: 调整防火墙规则

确保你的服务器的防火墙规则允许HTTP(端口80)和HTTPS(端口443,如果你使用SSL)的流量。如果你正在使用ufw,可以使用以下命令:

1
2
sudo ufw allow 'Nginx Full'
sudo ufw reload

步骤 5: 测试配置

在浏览器中输入 http://clip.lthero.top 或使用命令行工具如 curl 来测试你的配置:

1
curl http://clip.lthero.top

你应该能看到从Docker容器中运行的服务响应的内容。

这样,你就配置好了Nginx作为反向代理,将域名 clip.lthero.top 的流量转发到宿主机的9501端口上的服务。如果你希望使用HTTPS,你还需要设置SSL证书,可以考虑使用Let’s Encrypt免费证书并配置HTTPS。

使用HTTPS访问

要让你的域名 clip.lthero.top 使用 HTTPS,你需要获取 SSL/TLS 证书,并配置 Nginx 以使用这些证书来加密网页内容。以下是详细的步骤,包括如何使用 Let’s Encrypt 提供的免费证书自动化这个过程。

步骤 1: 安装 Certbot

Certbot 是一个自动获取并安装 Let’s Encrypt 证书的客户端。在 Ubuntu 上安装 Certbot 及其 Nginx 插件非常简单:

1
2
sudo apt update
sudo apt install certbot python3-certbot-nginx

步骤 2: 获取和安装证书

使用 Certbot 获取并为你的域名安装证书:

1
sudo certbot --nginx -d clip.lthero.top

此命令会自动为指定的域名 clip.lthero.top 配置 SSL 证书,并更新 Nginx 配置以使用这些证书。Certbot 会询问你一些问题,比如电子邮件地址(用于紧急联系和证书续订提醒),以及是否重定向所有 HTTP 请求到 HTTPS(强烈建议启用)。

步骤 3: 更新 Nginx 配置

如果你想手动编辑 Nginx 配置文件,可以按以下方式配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
server {
listen 80;
server_name clip.lthero.top;
return 301 https://$server_name$request_uri; # 强制重定向所有 HTTP 请求到 HTTPS
}


server {
listen 443 ssl http2;
server_name clip.lthero.top;

ssl_certificate /etc/letsencrypt/live/clip.lthero.top/fullchain.pem; # 证书文件路径
ssl_certificate_key /etc/letsencrypt/live/clip.lthero.top/privkey.pem; # 私钥文件路径

ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # 缓存 SSL 会话以提升性能
ssl_session_tickets off;

# 现代加密套件配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;

# 其他 SSL 优化设置
ssl_stapling on;
ssl_stapling_verify on;

# 允许最大请求体大小为 1000MB
client_max_body_size 1000m;
location / {
auth_basic "Restricted Content";
auth_basic_user_file /etc/nginx/.htpasswd; # 指向你的密码文件

proxy_pass http://localhost:9501;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

这个配置不仅启用了 HTTPS,还包括了一些现代的安全实践,如启用 HTTP/2,配置加密套件和协议等。

允许最大请求体大小为 1000MB

client_max_body_size 1000m;

这行最好与config.js中保持一致,如果nginx设置小了,会出现“上传失败”的结果!

步骤 4: 重新加载 Nginx

更改配置后,需要重新加载 Nginx 以应用新的配置:

1
2
sudo nginx -t  # 检查配置文件是否有语法错误
sudo systemctl reload nginx # 重新加载配置

步骤 5: 验证 HTTPS

在浏览器中访问 https://clip.lthero.top 来检查是否配置成功。你应该能够看到一个安全锁标志,表明连接是通过 HTTPS 加密的。

步骤 6: 自动续订证书

Let’s Encrypt 的证书有效期为90天,因此建议设置自动续订:

1
sudo certbot renew --dry-run

这个命令会测试证书续订过程。如果这个测试成功,Certbot 会在系统的定时任务中安排自动续订。

通过以上步骤,你的站点 clip.lthero.top 现在应该能够安全地使用 HTTPS 进行通信了。


密码认证

要求用户在访问 clip.lthero.top 域名时输入密码,可以通过在 Nginx 服务器上配置基本的 HTTP 认证来实现。这需要设置用户名和密码,以及修改 Nginx 的配置文件来要求认证。以下是详细步骤:

步骤 1: 创建密码文件

首先,你需要创建一个密码文件来存储用户名和经过加密的密码。这通常使用 htpasswd 工具完成,该工具随 Apache 提供的 apache2-utils 包一起安装。如果你的系统上还没有安装这个包,可以使用以下命令安装:

1
2
sudo apt update
sudo apt install apache2-utils

接着,创建密码文件并添加用户。例如,创建一个名为 clipadmin 的用户:

1
sudo htpasswd -c /etc/nginx/.htpasswd clipadmin

系统会提示你输入并确认密码。-c 选项用于创建新文件,如果已经有文件存在且仅需要添加或更新用户,不要使用 -c 选项。

步骤 2: 配置 Nginx 以要求认证

编辑你的 Nginx 配置文件,通常位于 /etc/nginx/sites-available/clip.lthero.top,在适当的 locationserver 块中添加认证配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 443 ssl http2;
server_name clip.lthero.top;

ssl_certificate /etc/letsencrypt/live/clip.lthero.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/clip.lthero.top/privkey.pem;

location / {
auth_basic "Restricted Content";
auth_basic_user_file /etc/nginx/.htpasswd; # 指向你的密码文件

proxy_pass http://localhost:9501;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

这里的 auth_basic 指令启用基本认证,并设置提示信息为 “Restricted Content”。auth_basic_user_file 指定了包含用户名和密码的文件路径。

步骤 3: 重新加载 Nginx

更改配置后,确保测试 Nginx 配置文件没有错误,然后重新加载 Nginx 使配置生效:

1
2
sudo nginx -t
sudo systemctl reload nginx

步骤 4: 测试认证功能

现在,当你访问 https://clip.lthero.top 时,浏览器应该会弹出一个登录对话框要求输入用户名和密码。只有正确输入认证信息后,用户才能访问站点内容。

这样你就完成了为 clip.lthero.top 配置基本 HTTP 认证的所有步骤,增加了一个访问该站点的安全层。

修改用户名

对于修改用户名,htpasswd 工具本身不提供直接修改用户名的选项。你需要先删除旧的用户名,然后添加新的用户名与密码。这里是如何操作的:

  1. 删除旧用户名:
    使用 htpasswd 命令删除旧的用户名 clipadmin

    1
    sudo htpasswd -D /etc/nginx/.htpasswd clipadmin

    这个命令会从指定的密码文件中删除 clipadmin 用户。

  2. 添加新用户名:
    现在,你可以添加新的用户名 lthero,并为其设置密码:

    1
    sudo htpasswd -b /etc/nginx/.htpasswd lthero 新密码

    -b 参数允许你直接在命令行中提供密码,这可以简化脚本中的使用。但请注意,这种方式可能会导致密码暴露在历史记录中。如果安全是一个关注点,应该省略 -b 参数,命令将提示你输入密码。

确保 Nginx 使用更新的密码文件

完成用户名更新后,无需修改 Nginx 的配置,只要确保使用的是同一个 .htpasswd 文件。你可以通过重载 Nginx 来确保所有设置都是最新的:

1
2
sudo nginx -t  # 检查配置是否正确
sudo systemctl reload nginx # 应用更改

这样就完成了用户名的更改,用户在访问 clip.lthero.top 时现在需要使用新的用户名 lthero 和其密码进行认证。


命令行上传文件|上传内容

【curl】上传文字内容

1
curl -X POST -H "Content-Type: text/plain" -d "这里是您要发送的文本" https://clip.lthero.top/text?room=test
  • ?room参数一定要有
  • ?room= 表示公共房间
  • ?room=test则是上传到test房间

【python】上传文字内容

upload_text.py

支持同时上传多段内容,支持中文,支持从标准输入流作参数如echo, cat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import sys
import requests
import argparse

def send_text(text_url, data, room):
text_url += f"?room={room}"
headers = {'Content-Type': 'text/plain; charset=utf-8'}
response = requests.post(text_url, data=data.encode('utf-8'), headers=headers)
return response

def main():
parser = argparse.ArgumentParser(description='Send text to a specified room via the web API.')
parser.add_argument('data', nargs='*', type=str, help='The text data to send directly as arguments.')
parser.add_argument('--room', type=str, default="", help='The name of the room (optional).')
args = parser.parse_args()

text_url = 'https://clip.lthero.top/text'

# Handle multiple command-line arguments or single stdin input
if args.data:
# If data arguments are provided, send each one
for data in args.data:
response = send_text(text_url, data, args.room)
if response.status_code == 200:
print(f"发送成功: {data}")
else:
print(f"发送失败,状态码 {response.status_code}: {response.text}, 内容: {data}")
elif not sys.stdin.isatty():
# If no data arguments but stdin has data
data = sys.stdin.read().strip()
#for data in datas:
if data:
response = send_text(text_url, data, args.room)
if response.status_code == 200:
print(f"发送成功: {data}")
else:
print(f"发送失败,状态码 {response.status_code}: {response.text}")
else:
print("未接收到待发送的数据,请从标准输入流或从参数传入数据.")

if __name__ == '__main__':
main()
  • 使用命令:
  • python upload_text.py 1234 上传1234到公共房间
  • python upload_text.py "这是第一条消息" "这是第二条消息" --room test 上传"这是第一条消息" "这是第二条消息"到test房间
  • echo "11111111" "22222"| python upload_text.py 从echo传输多次数据到upload_text.py
  • cat upload_text.py | python upload_text.py --room lthero 从cat传输数据到upload_text.py

【python】上传文件

upload_file.py

无进度条版本

支持同时上传多个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import requests
import os
import argparse

def upload_file(url, file_paths, room):
for file_path in file_paths:
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)

# 初始化上传
init_response = requests.post(f'{url}/upload', data=file_name, headers={'Content-Type': 'text/plain'})
if init_response.status_code != 200:
print(f"初始化失败: {file_name}, {init_response.text}")
continue

uuid = init_response.json()['result']['uuid']

# 分块上传文件
chunk_size = 12582912 # 12MB
with open(file_path, 'rb') as f:
uploaded_size = 0
while uploaded_size < file_size:
chunk = f.read(chunk_size)
chunk_response = requests.post(f'{url}/upload/chunk/{uuid}', data=chunk, headers={'Content-Type': 'application/octet-stream'})
if chunk_response.status_code != 200:
print(f"上传块失败: {file_name}, {chunk_response.text}")
break
uploaded_size += len(chunk)

# 完成上传
finish_response = requests.post(f'{url}/upload/finish/{uuid}', params={'room': room})
if finish_response.status_code != 200:
print(f"完成上传失败: {file_name}, {finish_response.text}")
else:
print(f"文件上传成功: {file_name}")

def main():
parser = argparse.ArgumentParser(description='File Upload Script')
parser.add_argument('file_paths', type=str, nargs='+', help='Path(s) to the files to be uploaded')
parser.add_argument('--room', type=str, default='', help='Room name for the upload context (optional)')

args = parser.parse_args()

upload_url = 'https://clip.lthero.top'
upload_file(upload_url, args.file_paths, args.room)

if __name__ == '__main__':
main()

  • 使用命令:
  • python upload_file.py /media/Stuff/config.json 上传config.json文件到公共房间
  • python upload_file.py /media/Stuff/config.json --room test 上传config.json文件到test房间
  • python upload_file.py file1.txt file2.jpg file3.pdf --room test 上传多个文件到test房间
  • python upload_file.py file* --room test 支持符号写法,上传多个文件(file1.txt file2.jpg file3.pdf)到test房间

有进度条版本

必须在python安装tqdmpip3 install tqdmpip install tqdm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import requests
import os
import argparse
from tqdm import tqdm

def upload_file(url, file_paths, room):
for file_path in file_paths:
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)

# 初始化上传
init_response = requests.post(f'{url}/upload', data=file_name, headers={'Content-Type': 'text/plain'})
if init_response.status_code != 200:
print(f"初始化失败: {file_name}, {init_response.text}")
continue

uuid = init_response.json()['result']['uuid']

# 分块上传文件
chunk_size = 12582912 # 12MB
with open(file_path, 'rb') as f, tqdm(total=file_size, unit='B', unit_scale=True, desc=file_name) as progress:
uploaded_size = 0
while uploaded_size < file_size:
chunk = f.read(chunk_size)
chunk_response = requests.post(f'{url}/upload/chunk/{uuid}', data=chunk, headers={'Content-Type': 'application/octet-stream'})
if chunk_response.status_code != 200:
print(f"上传块失败: {file_name}, {chunk_response.text}")
break
chunk_size = len(chunk)
uploaded_size += chunk_size
progress.update(chunk_size)

# 完成上传
finish_response = requests.post(f'{url}/upload/finish/{uuid}', params={'room': room})
if finish_response.status_code != 200:
print(f"完成上传失败: {file_name}, {finish_response.text}")
else:
print(f"文件上传成功: {file_name}")

def main():
parser = argparse.ArgumentParser(description='File Upload Script')
parser.add_argument('file_paths', type=str, nargs='+', help='Path(s) to the files to be uploaded')
parser.add_argument('--room', type=str, default='', help='Room name for the upload context (optional)')

args = parser.parse_args()

upload_url = 'https://clip.lthero.top'
upload_file(upload_url, args.file_paths, args.room)

if __name__ == '__main__':
main()

全局使用

要在全局任意路径都可以使用 upload_file.pyupload_text.py 这两个脚本,涉及到以下几个步骤:

1. 将脚本移动到全局可访问的路径

将脚本放在一个所有用户都能访问的目录中,如 /usr/local/bin。这个目录通常已经在大多数 Linux 发行版的环境变量 $PATH 中,所以放在这里的程序可以被全局调用。

首先,确保脚本具有可执行权限:

1
2
chmod +x upload_file.py
chmod +x upload_text.py

然后,将它们移动到 /usr/local/bin 目录:

1
2
sudo cp upload_file.py /usr/local/bin/upFile2ClipBoard
sudo cp upload_text.py /usr/local/bin/upText2ClipBoard

2. 修改脚本的首行(Shebang)

修改/usr/local/bin/upFile2ClipBoard/usr/local/bin/upText2ClipBoard

确保脚本的第一行指向 Python 解释器的正确路径。这被称为 “shebang” 行

1
#!/usr/bin/env python3

如果不知道python路径,使用which python可以查询出来

1
2
$ which python
/opt/anaconda3/bin/python

则需要添加#!/opt/anaconda3/bin/python/usr/local/bin/upText2ClipBoard最上面

3. 更新环境变量(如果需要)

如果将脚本放在除了 /usr/local/bin 之外的目录,可能需要将该目录添加到环境变量 $PATH 中。编辑 shell 配置文件(例如 ~/.bashrc~/.zshrc),添加以下行:

1
export PATH="$PATH:/path/to/your/script_directory"

之后,运行 source ~/.bashrc (或对应的配置文件),以使更改生效。

4. 直接调用脚本

完成上述步骤后,应该能够从任何目录直接调用 upload_fileupload_text,如下所示:

1
2
3
4
upFile2ClipBoard somefile.txt
upText2ClipBoard "Here is some text" --room "test"
echo "11111111" "22222" | upText2ClipBoard
cat upload_file.py | upText2ClipBoard

微信

项目:https://github.com/wangrongding/wechat-bot

config.js修改成这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义机器人的名称,这里是为了防止群聊消息太多,所以只有艾特机器人才会回复,
// 这里不要把@去掉,在@后面加上你启动机器人账号的微信名称
export const botName = '@Lthero'

export const uploadTextURL='https://clip.lthero.top/text'

export const uploadFileURL='https://clip.lthero.top'

// 群聊白名单,白名单内的群聊才会自动回复
export const roomWhiteList = ['LtheroG', 'YesRight']

// 联系人白名单,白名单内的联系人才会自动回复
export const aliasWhiteList = ['lthero','Lthero_','Jntm']

sendMessage.js修改成这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import { botName, roomWhiteList, aliasWhiteList, uploadTextURL,uploadFileURL } from '../../config.js'
import { getServe } from './serve.js'
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import { promises } from 'fs';

/**
* Uploads text to a specified URL.
* @param {string} text_url - Base URL for the text upload.
* @param {string} data - Text data to be uploaded.
* @param {string} cliproom - The room parameter to be appended to the URL.
* @returns {Promise} The HTTP response.
*/
async function send_text(text_url, data, cliproom = '') {
text_url += `?room=${cliproom}`;
try {
const response = await axios.post(text_url, data, {
headers: {'Content-Type': 'text/plain; charset=utf-8'}
});
return response;
} catch (error) {
console.error('HTTP request failed:', error);
throw error; // Rethrow to handle it in the calling function
}
}

async function upText2ClipBoard(uploadURL, isRoom, content ,remarkName=""){

let args = content.split(' ');
const cliproomIndex = args.indexOf('--room');
let data, cliproom;
cliproom="";
if (cliproomIndex > -1) {
cliproom = args[cliproomIndex + 1];
// Combine all elements after "--room <roomname>" into the data string
data = args.slice(cliproomIndex + 2).join(' ');
} else {
// If there is no "--room", treat all arguments after "upt" as data
data = args.slice(1).join(' ');
cliproom = remarkName; // Use a remarkName room if none specified
}
try {
const response = await send_text(uploadURL, data, cliproom);
if (response.status === 200) {
if(isRoom){
return `内容上传成功`
}else{
return `内容上传成功, 房间: ${cliproom}`
}
} else {
return `内容上传失败,状态码:${response.status}`
}
} catch (error) {
return `出错啦:${error.message}`
}
return;

}

async function checkAndUploadFile(uploadURL, isRoom,filePath,remarkName='') {
try {
// 检查文件是否存在
await promises.access(filePath);
console.log(`🌸🌸🌸 文件收到,准备上传:${filePath}`);
// 如果文件存在,则上传
return await uploadFile2ClipBoard(uploadURL, isRoom, filePath,remarkName);
} catch (error) {
console.error(`文件检查失败或不存在: ${error}`);
}
}

async function uploadFile2ClipBoard(uploadUrl, isRoom, filePath,room='') {

const fileName = path.basename(filePath);
// console.log('🌸🌸🌸 filePath: ', filePath)
// console.log('🌸🌸🌸 fileName: ', fileName)
const fileSize = fs.statSync(filePath).size;
// console.log('🌸🌸🌸 fileSize: ', fileSize)

try {
// Initialize upload
const initUrl = `${uploadUrl}/upload`;
// console.log('🌸🌸🌸 initUrl: ', initUrl)
const initResponse = await axios.post(initUrl, fileName, {
headers: { 'Content-Type': 'text/plain' }
});

// console.log('🌸🌸🌸 initResponse: ', initResponse)
if (initResponse.status !== 200) {
throw new Error(`初始化失败: ${initResponse.data}_${initResponse.status}`);
}

const uuid = initResponse.data.result.uuid;
const chunkSize = 12582912; // 12 MB

// Read and upload the file in chunks
const stream = fs.createReadStream(filePath, { highWaterMark: chunkSize });
let uploadedSize = 0;

for await (const chunk of stream) {
const chunkUrl = `${uploadUrl}/upload/chunk/${uuid}`;
const chunkResponse = await axios.post(chunkUrl, chunk, {
headers: { 'Content-Type': 'application/octet-stream' }
});
if (chunkResponse.status !== 200) {
throw new Error(`分块上传失败: ${chunkResponse.data}`);
}
uploadedSize += chunk.length;
}

// Complete the upload
const finishUrl = `${uploadUrl}/upload/finish/${uuid}`;
const finishParams = { params: { room } };
const finishResponse = await axios.post(finishUrl, null, finishParams);
if (finishResponse.status !== 200) {
throw new Error(`Finish upload failed: ${finishResponse.data}`);
}
await promises.unlink(filePath);

console.log(`🌸🌸🌸 文件上传成功!房间: ${room}`);
console.log('🌸🌸🌸 本地文件删除成功!');
if(isRoom){
return `文件上传成功! ${fileName}`
}
return `文件上传成功! ${fileName} , 房间: ${room}`
} catch (error) {
console.error(`Error uploading file: ${fileName}, ${error}`);
return `Error uploading file: ${fileName}, ${error}`
}
}


/**
* 默认消息发送
* @param msg
* @param bot
* @param ServiceType 服务类型 'GPT' | 'Kimi'
* @returns {Promise<void>}
*/
export async function defaultMessage(msg, bot, ServiceType = 'GPT') {
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 // 是否是机器人自己

// TODO 你们可以根据自己的需求修改这里的逻辑
if (isBotSelf) return // 如果是机器人自己发送的消息

try {
console.log('🌸🌸🌸 / msg_type: ', msg.type())

// if (content.startsWith('/upf')) {
// console.log('🌸🌸🌸 / msg_type: ', msg.type())
// console.log('🌸🌸🌸 / upf: ', msg)
// }

// 上传文件
if ( isAlias && msg.type() === bot.Message.Type.Attachment || msg.type()===6 ||msg.type()===5 ||msg.type()===15) {
console.log('🌸🌸🌸 收到文件消息,准备下载...');
// 创建文件流
const fileBox = await msg.toFileBox();
const path = "/app/src/TempFiles/"
const fileName = path+fileBox.name;

// 保存文件到本地
await fileBox.toFile(fileName, true);

// 检查文件存在并上传
const response = await checkAndUploadFile(uploadFileURL,room,fileName,remarkName);
if (room){
await room.say(response);
}else{
await contact.say(response);
}
return
}

// 区分群聊和私聊
if (isText && isRoom && room ) {
const question = await msg.mentionText() || content.replace(`${botName}`, '') // 去掉艾特的消息主体
console.log('🌸🌸🌸 / question: ', question)
// 使用upt上传文字,私聊回复
if (question.startsWith('upt')) {
// 无contract
const response = await upText2ClipBoard(uploadTextURL,room, question, remarkName)
await room.say(response)
}else{
// 使用gpt
const response = await getReply(question)
await room.say(response)
}
}
// 私人聊天,白名单内的直接发送
if (isText && isAlias && !room ) {
// 用gpt
if(content.startsWith('gpt')){
console.log('🌸🌸🌸 / content: ', content)
const response = await getReply(content)
await contact.say(response)
}
// 使用upt命令上传文字
if (content.startsWith('upt')) {
const response = await upText2ClipBoard(uploadTextURL,room, content , remarkName);
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
}

在.env文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# .env

# 选择model
model="gpt-3"
# OpenAi 的api key, 去 https://beta.openai.com/account/api-keys 中生成一个即可
OPENAI_API_KEY='sk-xxxxxxx'
# endpoint默认用https://api.openai.com
# API_ENDPOINT='https://api.openai.com'


# Kimi 的api key, 去 https://platform.moonshot.cn/console/api-keys
KIMI_API_KEY=''

# 科大讯飞, 去 https://console.xfyun.cn/services
XUNFEI_APP_ID=''
XUNFEI_API_KEY=''
XUNFEI_API_SECRET=''

在openai目录下的index.js修改成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { remark } from 'remark'
import stripMarkdown from 'strip-markdown'
import { Configuration, OpenAIApi } from 'openai'
import axios from 'axios' // 引入axios进行HTTP请求

import dotenv from 'dotenv'
const env = dotenv.config().parsed // 环境参数
const models={"gpt4a":"gpt-4-all","gpt3":"gpt-3.5-turbo","gpt4t":"gpt-4-turbo-2024-04-09","gpt4":"gpt-4-0613","gpt":"gpt-3.5-turbo"}
const configuration = new Configuration({
apiKey: env.OPENAI_API_KEY,
})
const openai = new OpenAIApi(configuration)

export async function getGptReply(prompt,model="") {
console.log('🚀🚀🚀 / prompt', prompt)
//let chosen_model = 'text-davinci-003'
let chosen_model
if(model==""){
chosen_model = env.model
}
else{
chosen_model = models[model]
}
let reply = ''
if (chosen_model == 'text-davinci-003') {
console.log('🚀🚀🚀 / Using model', chosen_model)
const response = await openai.createCompletion({
model: chosen_model,
prompt: prompt,
temperature: 0.8, // 每次返回的答案的相似度0-1(0:每次都一样,1:每次都不一样)
max_tokens: 4_000,
top_p: 1,
frequency_penalty: 0.0,
presence_penalty: 0.6,
stop: [' Human:', ' AI:'],
})

reply = markdownToText(response.data.choices[0].text)
} else if (chosen_model == 'gpt-3.5-turbo') {
console.log('🚀🚀🚀 / Using model', chosen_model)
const response = await openai.createChatCompletion({
model: chosen_model,
messages: [
{ "role": "system", content: "You are a personal assistant." },
{ "role": "user", content: prompt }
]
})

reply = markdownToText(response.data.choices[0].message.content)
}else{
console.log('🚀🚀🚀 / Using model', chosen_model)

const response = await axios.post(env.API_ENDPOINT, {
model: chosen_model,
messages: [
{ "role": "system", content: "You are a helpful assistant." },
{ "role": "user", content: prompt }
],
max_tokens: 1500,
temperature: 0.7,
top_p: 1,
frequency_penalty: 0.0,
presence_penalty: 0.6,
stop: [' Human:', ' AI:'],
}, {
headers: {
'Authorization': `Bearer ${env.OPENAI_API_KEY}`, // Make sure your API key is correctly set
'Content-Type': 'application/json'
}
});
// Extract and clean the markdown response
reply = markdownToText(response.data.choices[0].message.content);
}
console.log('🚀🚀🚀 / reply', reply)
return `${reply}\nVia ${chosen_model}`
}

function markdownToText(markdown) {
return remark()
.use(stripMarkdown)
.processSync(markdown ?? '')
.toString()
}



另外,在src/wechaty/index.js修改以下内容,从而只使用ChatGPT,以避免要用户选择

1
2
3
4
function init() {
inquirer
handleStart('ChatGPT')
}

修改完成后,进行docker打包

1
sudo docker build . -t wechat-bot

运行docker容器

1
sudo docker run -d -it --rm --name wechat-bot -v $(pwd)/config.js:/app/config.js -v $(pwd)/.env:/app/.env wechat-bot
  • 这里的-it一定要加,否则运行后可能会自动停止

随后,使用下面的命令查看日志,运行时会跳出来二维码,扫码登录即可

1
sudo docker logs -f wechat-bot

或用我打包的镜像

1
2
sudo docker pull lthero1/wechat-bot-lthero:latest
sudo docker run -d -it --rm --name wechat-bot -v $(pwd)/config.js:/app/config.js -v $(pwd)/.env:/app/.env lthero1/wechat-bot-lthero:latest

运行后效果

  1. 给机器人微信账号发送文件、图像等会直接上传到微信备注的房间
  2. 使用upt --room test xxxxxxxxx 可以将内容发送到test房间
  3. 使用upt xxxxxxxxx 可以将内容发送到公共房间
  4. 直接发送内容会使用chatgpt3.5

代码的修改

在原代码基础上添加了支持markdown语法的功能

SendText.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<template>
<div>
<div class="headline text--primary mb-4">发送文本</div>
<v-textarea
outlined
dense
rows="6"
:counter="$root.config.text.limit"
placeholder="请输入需要发送的文本"
v-model="$root.send.text"
></v-textarea>
<div v-html="renderedContent" class="rendered-markdown"></div>
<div class="text-right">
<v-btn
color="primary"
:block="$vuetify.breakpoint.smAndDown"
:disabled="!$root.send.text || !$root.websocket || $root.send.text.length > $root.config.text.limit"
@click="send"
>发送</v-btn>
</div>
</div>
</template>

<style>
.rendered-markdown {
max-height: 300px; /* 设置最大高度,超过这个高度的内容将通过滚动条显示 */
overflow-y: auto; /* 垂直方向上,如果内容超出则显示滚动条 */
padding: 10px; /* 可选,为内容添加一些内边距 */
border: 1px solid #ccc; /* 可选,为容器添加边框,使之更清晰 */
background-color: #f9f9f9; /* 可选,设置背景颜色 */
margin-bottom: 10px; /* 可选,为容器添加下外边距 */
}
</style>

<script>
import MarkdownIt from 'markdown-it';
export default {
name: 'send-text',
data() {
return {
md: new MarkdownIt(),
renderedContent: '',
};
},
watch: {
// 当文本变化时,重新渲染 Markdown
'$root.send.text': function(newVal) {
this.renderedContent = this.md.render(newVal);
}
},
methods: {
send() {
this.$http.post(
'/text',
this.$root.send.text,
{
params: new URLSearchParams([['room', this.$root.room]]),
headers: {
'Content-Type': 'text/plain',
},
},
).then(response => {
this.$toast('发送成功');
this.$root.send.text = '';
}).catch(error => {
if (error.response && error.response.data.msg) {
this.$toast(`发送失败:${error.response.data.msg}`);
} else {
this.$toast('发送失败');
}
});
},
},
}

</script>

Text.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<template>
<v-hover
v-slot:default="{ hover }"
>
<v-card :elevation="hover ? 6 : 2" class="mb-2 transition-swing">
<v-card-text>
<div class="d-flex flex-row align-center">
<div class="flex-grow-1 mr-2" style="min-width: 0">
<div class="title text-truncate text--primary" @click="expand = !expand">
文本消息<v-icon>{{expand ? mdiChevronUp : mdiChevronDown}}</v-icon>
</div>
<div class="text-truncate" @click="expand = !expand" v-html="meta.content.trim()" v-linkified></div>
</div>

<div class="align-self-center text-no-wrap">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon color="grey" @click="copyText">
<v-icon>{{mdiContentCopy}}</v-icon>
</v-btn>
</template>
<span>复制</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn v-on="on" icon color="grey" @click="deleteItem">
<v-icon>{{mdiClose}}</v-icon>
</v-btn>
</template>
<span>删除</span>
</v-tooltip>
</div>
</div>
<v-expand-transition>
<div v-show="expand">
<v-divider class="my-2"></v-divider>
<div ref="content" v-html="renderMarkdown(meta.content)" v-linkified></div>
</div>
</v-expand-transition>
</v-card-text>
</v-card>
</v-hover>
</template>

<script>
import MarkdownIt from 'markdown-it';
import {
mdiChevronUp,
mdiChevronDown,
mdiContentCopy,
mdiClose,
} from '@mdi/js';

export default {
name: 'received-text',
props: {
meta: {
type: Object,
default() {
return {};
},
},
},
data() {
return {
expand: false,
mdiChevronUp,
mdiChevronDown,
mdiContentCopy,
mdiClose,
md: new MarkdownIt(), // Markdown 解析器实例
};
},
methods: {
renderMarkdown(text) {
return this.md.render(text);
},
copyText() {
let el = document.createElement('textarea');
el.value = new DOMParser().parseFromString(this.meta.content, 'text/html').documentElement.textContent;
el.style.cssText = 'top:0;left:0;position:fixed';
document.body.appendChild(el);
el.focus();
el.select();
document.execCommand('copy');
document.body.removeChild(el);
this.$toast('复制成功');
},
deleteItem() {
this.$http.delete(`/revoke/${this.meta.id}`, {
params: new URLSearchParams([['room', this.$root.room]]),
}).then(() => {
this.$toast('已删除文本消息');
}).catch(error => {
if (error.response && error.response.data.msg) {
this.$toast(`消息删除失败:${error.response.data.msg}`);
} else {
this.$toast('消息删除失败');
}
});
},
},

}
</script>