@zxh126/pi-qqbot 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +370 -314
- package/README.md +518 -439
- package/approver.ts +174 -174
- package/attachment.ts +502 -502
- package/billing.ts +579 -0
- package/config.ts +281 -280
- package/constants.ts +51 -51
- package/docs/README.zh-CN.md +477 -410
- package/index.ts +1856 -1571
- package/package.json +60 -60
- package/qq/api.ts +107 -107
- package/qq/gateway.ts +265 -265
- package/qq/upload.ts +288 -288
- package/remote.ts +81 -81
- package/selfupdate.ts +212 -0
- package/slash.ts +862 -826
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"keywords": [
|
|
8
|
-
"pi-package",
|
|
9
|
-
"pi",
|
|
10
|
-
"qq",
|
|
11
|
-
"qqbot",
|
|
12
|
-
"bot",
|
|
13
|
-
"coding-agent",
|
|
14
|
-
"approval"
|
|
15
|
-
],
|
|
16
|
-
"repository": {
|
|
17
|
-
"type": "git",
|
|
18
|
-
"url": "git+https://gitee.com/zxh126/pi-qqbot.git"
|
|
19
|
-
},
|
|
20
|
-
"homepage": "https://gitee.com/zxh126/pi-qqbot#readme",
|
|
21
|
-
"bugs": {
|
|
22
|
-
"url": "https://gitee.com/zxh126/pi-qqbot/issues"
|
|
23
|
-
},
|
|
24
|
-
"pi": {
|
|
25
|
-
"extensions": [
|
|
26
|
-
"./index.ts"
|
|
27
|
-
]
|
|
28
|
-
},
|
|
29
|
-
"scripts": {
|
|
30
|
-
"test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/remote-notice.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs"
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"*.ts",
|
|
34
|
-
"qq",
|
|
35
|
-
"util",
|
|
36
|
-
"README.md",
|
|
37
|
-
"CHANGELOG.md",
|
|
38
|
-
"docs",
|
|
39
|
-
"LICENSE"
|
|
40
|
-
],
|
|
41
|
-
"dependencies": {
|
|
42
|
-
"proper-lockfile": "^4.1.2",
|
|
43
|
-
"ws": "^8.18.0"
|
|
44
|
-
},
|
|
45
|
-
"peerDependencies": {
|
|
46
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
47
|
-
"typebox": "*"
|
|
48
|
-
},
|
|
49
|
-
"peerDependenciesMeta": {
|
|
50
|
-
"typebox": {
|
|
51
|
-
"optional": true
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
"devDependencies": {
|
|
55
|
-
"@types/proper-lockfile": "^4.1.4",
|
|
56
|
-
"@types/ws": "^8.18.2",
|
|
57
|
-
"jiti": "^2.6.1",
|
|
58
|
-
"typebox": "^1.3.27"
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@zxh126/pi-qqbot",
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"qq",
|
|
11
|
+
"qqbot",
|
|
12
|
+
"bot",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"approval"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://gitee.com/zxh126/pi-qqbot.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://gitee.com/zxh126/pi-qqbot#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://gitee.com/zxh126/pi-qqbot/issues"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/remote-notice.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs && node test/error-notice.test.mjs && node test/billing.test.mjs && node test/credential-intake.test.mjs && node test/selfupdate.test.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"*.ts",
|
|
34
|
+
"qq",
|
|
35
|
+
"util",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"docs",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"proper-lockfile": "^4.1.2",
|
|
43
|
+
"ws": "^8.18.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"typebox": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
56
|
+
"@types/ws": "^8.18.2",
|
|
57
|
+
"jiti": "^2.6.1",
|
|
58
|
+
"typebox": "^1.3.27"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/qq/api.ts
CHANGED
|
@@ -1,107 +1,107 @@
|
|
|
1
|
-
import { MAX_REPLIES_PER_MSG_ID } from "../constants";
|
|
2
|
-
|
|
3
|
-
export interface QQApiOptions {
|
|
4
|
-
restBase: string;
|
|
5
|
-
getToken: () => Promise<string>;
|
|
6
|
-
fetchFn?: typeof fetch;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface SendOptions {
|
|
10
|
-
msgId?: string;
|
|
11
|
-
format?: "text" | "markdown";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface MediaSendOptions {
|
|
15
|
-
msgId?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class QQApi {
|
|
19
|
-
private queue: Promise<void> = Promise.resolve();
|
|
20
|
-
private seqCounters = new Map<string, number>();
|
|
21
|
-
|
|
22
|
-
constructor(private opts: QQApiOptions) {}
|
|
23
|
-
|
|
24
|
-
sendC2C(openid: string, content: string, options: SendOptions = {}): Promise<void> {
|
|
25
|
-
const task = this.queue.then(() => this.doSend(openid, content, options));
|
|
26
|
-
this.queue = task.catch(() => {});
|
|
27
|
-
return task;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 发富媒体(msg_type=7):fileInfo 来自 `/v2/users/{openid}/files` 的上传结果 */
|
|
31
|
-
sendC2CMedia(openid: string, fileInfo: string, options: MediaSendOptions = {}): Promise<void> {
|
|
32
|
-
const task = this.queue.then(() => this.doMedia(openid, fileInfo, options));
|
|
33
|
-
this.queue = task.catch(() => {});
|
|
34
|
-
return task;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
private nextSeq(msgId?: string): number | undefined {
|
|
38
|
-
if (!msgId) return undefined;
|
|
39
|
-
const used = this.seqCounters.get(msgId) ?? 0;
|
|
40
|
-
if (used >= MAX_REPLIES_PER_MSG_ID) return undefined;
|
|
41
|
-
const seq = used + 1;
|
|
42
|
-
this.seqCounters.set(msgId, seq);
|
|
43
|
-
if (this.seqCounters.size > 500) this.seqCounters.clear();
|
|
44
|
-
return seq;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private async postWithRetry(openid: string, body: any): Promise<void> {
|
|
48
|
-
const fetchFn = this.opts.fetchFn ?? fetch;
|
|
49
|
-
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
50
|
-
const token = await this.opts.getToken();
|
|
51
|
-
const res = await fetchFn(`${this.opts.restBase}/v2/users/${openid}/messages`, {
|
|
52
|
-
method: "POST",
|
|
53
|
-
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
54
|
-
body: JSON.stringify(body),
|
|
55
|
-
});
|
|
56
|
-
if (res.status === 429 && attempt < 3) {
|
|
57
|
-
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
|
|
58
|
-
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 30) * 1000));
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (!res.ok) throw new Error(`sendC2C failed: HTTP ${res.status} ${await res.text()}`);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
private async doMedia(openid: string, fileInfo: string, options: MediaSendOptions): Promise<void> {
|
|
67
|
-
let msgId = options.msgId;
|
|
68
|
-
let seq: number | undefined;
|
|
69
|
-
if (msgId) {
|
|
70
|
-
seq = this.nextSeq(msgId);
|
|
71
|
-
if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
|
|
72
|
-
}
|
|
73
|
-
const body: any = { msg_type: 7, media: { file_info: fileInfo } };
|
|
74
|
-
if (msgId && seq !== undefined) {
|
|
75
|
-
body.msg_id = msgId;
|
|
76
|
-
body.msg_seq = seq;
|
|
77
|
-
}
|
|
78
|
-
await this.postWithRetry(openid, body);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
private async doSend(openid: string, content: string, options: SendOptions): Promise<void> {
|
|
82
|
-
const format = options.format ?? "text";
|
|
83
|
-
let msgId = options.msgId;
|
|
84
|
-
let seq: number | undefined;
|
|
85
|
-
if (msgId) {
|
|
86
|
-
seq = this.nextSeq(msgId);
|
|
87
|
-
if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
|
|
88
|
-
}
|
|
89
|
-
const makeBody = (fmt: "text" | "markdown") => {
|
|
90
|
-
const body: any = fmt === "markdown" ? { msg_type: 2, markdown: { content } } : { msg_type: 0, content };
|
|
91
|
-
if (msgId && seq !== undefined) {
|
|
92
|
-
body.msg_id = msgId;
|
|
93
|
-
body.msg_seq = seq;
|
|
94
|
-
}
|
|
95
|
-
return body;
|
|
96
|
-
};
|
|
97
|
-
try {
|
|
98
|
-
await this.postWithRetry(openid, makeBody(format));
|
|
99
|
-
} catch (e) {
|
|
100
|
-
if (format === "markdown") {
|
|
101
|
-
await this.postWithRetry(openid, makeBody("text"));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
throw e;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
1
|
+
import { MAX_REPLIES_PER_MSG_ID } from "../constants";
|
|
2
|
+
|
|
3
|
+
export interface QQApiOptions {
|
|
4
|
+
restBase: string;
|
|
5
|
+
getToken: () => Promise<string>;
|
|
6
|
+
fetchFn?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SendOptions {
|
|
10
|
+
msgId?: string;
|
|
11
|
+
format?: "text" | "markdown";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MediaSendOptions {
|
|
15
|
+
msgId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class QQApi {
|
|
19
|
+
private queue: Promise<void> = Promise.resolve();
|
|
20
|
+
private seqCounters = new Map<string, number>();
|
|
21
|
+
|
|
22
|
+
constructor(private opts: QQApiOptions) {}
|
|
23
|
+
|
|
24
|
+
sendC2C(openid: string, content: string, options: SendOptions = {}): Promise<void> {
|
|
25
|
+
const task = this.queue.then(() => this.doSend(openid, content, options));
|
|
26
|
+
this.queue = task.catch(() => {});
|
|
27
|
+
return task;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 发富媒体(msg_type=7):fileInfo 来自 `/v2/users/{openid}/files` 的上传结果 */
|
|
31
|
+
sendC2CMedia(openid: string, fileInfo: string, options: MediaSendOptions = {}): Promise<void> {
|
|
32
|
+
const task = this.queue.then(() => this.doMedia(openid, fileInfo, options));
|
|
33
|
+
this.queue = task.catch(() => {});
|
|
34
|
+
return task;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private nextSeq(msgId?: string): number | undefined {
|
|
38
|
+
if (!msgId) return undefined;
|
|
39
|
+
const used = this.seqCounters.get(msgId) ?? 0;
|
|
40
|
+
if (used >= MAX_REPLIES_PER_MSG_ID) return undefined;
|
|
41
|
+
const seq = used + 1;
|
|
42
|
+
this.seqCounters.set(msgId, seq);
|
|
43
|
+
if (this.seqCounters.size > 500) this.seqCounters.clear();
|
|
44
|
+
return seq;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async postWithRetry(openid: string, body: any): Promise<void> {
|
|
48
|
+
const fetchFn = this.opts.fetchFn ?? fetch;
|
|
49
|
+
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
50
|
+
const token = await this.opts.getToken();
|
|
51
|
+
const res = await fetchFn(`${this.opts.restBase}/v2/users/${openid}/messages`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
});
|
|
56
|
+
if (res.status === 429 && attempt < 3) {
|
|
57
|
+
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
|
|
58
|
+
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 30) * 1000));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok) throw new Error(`sendC2C failed: HTTP ${res.status} ${await res.text()}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async doMedia(openid: string, fileInfo: string, options: MediaSendOptions): Promise<void> {
|
|
67
|
+
let msgId = options.msgId;
|
|
68
|
+
let seq: number | undefined;
|
|
69
|
+
if (msgId) {
|
|
70
|
+
seq = this.nextSeq(msgId);
|
|
71
|
+
if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
|
|
72
|
+
}
|
|
73
|
+
const body: any = { msg_type: 7, media: { file_info: fileInfo } };
|
|
74
|
+
if (msgId && seq !== undefined) {
|
|
75
|
+
body.msg_id = msgId;
|
|
76
|
+
body.msg_seq = seq;
|
|
77
|
+
}
|
|
78
|
+
await this.postWithRetry(openid, body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async doSend(openid: string, content: string, options: SendOptions): Promise<void> {
|
|
82
|
+
const format = options.format ?? "text";
|
|
83
|
+
let msgId = options.msgId;
|
|
84
|
+
let seq: number | undefined;
|
|
85
|
+
if (msgId) {
|
|
86
|
+
seq = this.nextSeq(msgId);
|
|
87
|
+
if (seq === undefined) msgId = undefined; // 被动额度用尽 → 主动消息
|
|
88
|
+
}
|
|
89
|
+
const makeBody = (fmt: "text" | "markdown") => {
|
|
90
|
+
const body: any = fmt === "markdown" ? { msg_type: 2, markdown: { content } } : { msg_type: 0, content };
|
|
91
|
+
if (msgId && seq !== undefined) {
|
|
92
|
+
body.msg_id = msgId;
|
|
93
|
+
body.msg_seq = seq;
|
|
94
|
+
}
|
|
95
|
+
return body;
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
await this.postWithRetry(openid, makeBody(format));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (format === "markdown") {
|
|
101
|
+
await this.postWithRetry(openid, makeBody("text"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|