qq-codex-bridge 0.1.3 → 0.1.4
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/.env.example +62 -0
- package/README.md +232 -287
- package/bin/chatgpt-desktop.js +2 -0
- package/bin/qq-codex-weixin-gateway.js +14 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
- package/dist/apps/bridge-daemon/src/cli.js +5 -1
- package/dist/apps/bridge-daemon/src/config.js +168 -37
- package/dist/apps/bridge-daemon/src/http-server.js +23 -11
- package/dist/apps/bridge-daemon/src/main.js +163 -29
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +309 -26
- package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
- package/dist/apps/weixin-gateway/src/cli.js +446 -0
- package/dist/apps/weixin-gateway/src/config.js +135 -0
- package/dist/apps/weixin-gateway/src/dev.js +2 -0
- package/dist/apps/weixin-gateway/src/message-store.js +50 -0
- package/dist/apps/weixin-gateway/src/server.js +216 -0
- package/dist/apps/weixin-gateway/src/state.js +163 -0
- package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
- package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
- package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
- package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
- package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
- package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
- package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
- package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
- package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
- package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
- package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
- package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
- package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
- package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
- package/dist/packages/ports/src/chat.js +1 -0
- package/dist/packages/store/src/session-repo.js +16 -3
- package/dist/packages/store/src/sqlite.js +3 -0
- package/package.json +8 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { parseQqMediaSegments } from "../../qq/src/qq-media-parser.js";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const BetterSqlite3 = require("better-sqlite3");
|
|
8
|
+
export class CodexLocalRolloutReader {
|
|
9
|
+
codexHomeDir;
|
|
10
|
+
sleep;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.codexHomeDir =
|
|
13
|
+
options.codexHomeDir ??
|
|
14
|
+
process.env.CODEX_HOME ??
|
|
15
|
+
path.join(os.homedir(), ".codex");
|
|
16
|
+
this.sleep =
|
|
17
|
+
options.sleep ??
|
|
18
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
19
|
+
}
|
|
20
|
+
captureCursorForThreadTitle(title) {
|
|
21
|
+
const normalizedTitle = title.trim();
|
|
22
|
+
if (!normalizedTitle) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const thread = this.findLatestThreadByTitle(normalizedTitle);
|
|
26
|
+
if (!thread?.rolloutPath) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!fs.existsSync(thread.rolloutPath)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
threadId: thread.threadId,
|
|
34
|
+
rolloutPath: thread.rolloutPath,
|
|
35
|
+
lineCount: countNonEmptyLines(thread.rolloutPath),
|
|
36
|
+
targetTurnId: null,
|
|
37
|
+
competingTurnStarted: false
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async waitForTurnCompletion(cursor, options) {
|
|
41
|
+
let currentCursor = cursor;
|
|
42
|
+
let targetTurnId = cursor.targetTurnId ?? null;
|
|
43
|
+
let competingTurnStarted = cursor.competingTurnStarted ?? false;
|
|
44
|
+
const commentaryMessages = [];
|
|
45
|
+
const seenCommentaryMessages = new Set();
|
|
46
|
+
let pendingFinalText = null;
|
|
47
|
+
for (let attempt = 0; attempt < options.pollAttempts; attempt += 1) {
|
|
48
|
+
const { nextCursor, events } = readRolloutEvents(currentCursor);
|
|
49
|
+
currentCursor = {
|
|
50
|
+
...nextCursor,
|
|
51
|
+
targetTurnId,
|
|
52
|
+
competingTurnStarted
|
|
53
|
+
};
|
|
54
|
+
for (const event of events) {
|
|
55
|
+
if (event.type !== "event_msg" || !event.payload) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const eventType = event.payload.type;
|
|
59
|
+
const eventTurnId = normalizeTurnId(event.payload.turn_id);
|
|
60
|
+
if (eventType === "task_started" && eventTurnId) {
|
|
61
|
+
if (!targetTurnId) {
|
|
62
|
+
targetTurnId = eventTurnId;
|
|
63
|
+
competingTurnStarted = false;
|
|
64
|
+
currentCursor.targetTurnId = targetTurnId;
|
|
65
|
+
currentCursor.competingTurnStarted = competingTurnStarted;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (eventTurnId !== targetTurnId) {
|
|
69
|
+
competingTurnStarted = true;
|
|
70
|
+
currentCursor.competingTurnStarted = competingTurnStarted;
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (eventType === "agent_message"
|
|
75
|
+
&& event.payload.phase === "commentary"
|
|
76
|
+
&& typeof event.payload.message === "string") {
|
|
77
|
+
if (!shouldCollectTurnScopedMessage(eventTurnId, targetTurnId, competingTurnStarted)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const commentary = event.payload.message.trim();
|
|
81
|
+
if (commentary && !seenCommentaryMessages.has(commentary)) {
|
|
82
|
+
commentaryMessages.push(commentary);
|
|
83
|
+
seenCommentaryMessages.add(commentary);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (eventType === "agent_message"
|
|
88
|
+
&& event.payload.phase === "final_answer"
|
|
89
|
+
&& typeof event.payload.message === "string") {
|
|
90
|
+
if (!shouldCollectTurnScopedMessage(eventTurnId, targetTurnId, competingTurnStarted)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const finalAnswer = event.payload.message.trim();
|
|
94
|
+
if (finalAnswer) {
|
|
95
|
+
pendingFinalText = finalAnswer;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (eventType === "task_complete") {
|
|
100
|
+
if (targetTurnId && eventTurnId && eventTurnId !== targetTurnId) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const finalText = normalizeFinalText(event.payload.last_agent_message, pendingFinalText);
|
|
104
|
+
if (!finalText) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!targetTurnId) {
|
|
108
|
+
targetTurnId = eventTurnId;
|
|
109
|
+
}
|
|
110
|
+
const fullText = joinMessageParts(commentaryMessages, finalText);
|
|
111
|
+
return {
|
|
112
|
+
turnId: targetTurnId,
|
|
113
|
+
commentaryMessages,
|
|
114
|
+
finalText,
|
|
115
|
+
fullText,
|
|
116
|
+
mediaReferences: extractMediaReferences(fullText)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (attempt + 1 < options.pollAttempts) {
|
|
121
|
+
await this.sleep(options.pollIntervalMs);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
findLatestThreadByTitle(title) {
|
|
127
|
+
const stateDbPath = path.join(this.codexHomeDir, "state_5.sqlite");
|
|
128
|
+
if (!fs.existsSync(stateDbPath)) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
let db = null;
|
|
132
|
+
try {
|
|
133
|
+
db = new BetterSqlite3(stateDbPath, {
|
|
134
|
+
readonly: true,
|
|
135
|
+
fileMustExist: true
|
|
136
|
+
});
|
|
137
|
+
const row = db
|
|
138
|
+
.prepare(`SELECT id AS threadId, rollout_path AS rolloutPath
|
|
139
|
+
FROM threads
|
|
140
|
+
WHERE archived = 0
|
|
141
|
+
AND title = ?
|
|
142
|
+
AND rollout_path IS NOT NULL
|
|
143
|
+
ORDER BY updated_at_ms DESC
|
|
144
|
+
LIMIT 1`)
|
|
145
|
+
.get(title);
|
|
146
|
+
return row ?? null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
db?.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function readRolloutEvents(cursor) {
|
|
157
|
+
if (!fs.existsSync(cursor.rolloutPath)) {
|
|
158
|
+
return {
|
|
159
|
+
nextCursor: cursor,
|
|
160
|
+
events: []
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const contents = fs.readFileSync(cursor.rolloutPath, "utf8");
|
|
164
|
+
const lines = splitNonEmptyLines(contents);
|
|
165
|
+
const safeOffset = Math.min(cursor.lineCount, lines.length);
|
|
166
|
+
const newLines = lines.slice(safeOffset);
|
|
167
|
+
const events = newLines.flatMap((line) => {
|
|
168
|
+
try {
|
|
169
|
+
return [JSON.parse(line)];
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
nextCursor: {
|
|
177
|
+
...cursor,
|
|
178
|
+
lineCount: lines.length
|
|
179
|
+
},
|
|
180
|
+
events
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function countNonEmptyLines(filePath) {
|
|
184
|
+
if (!fs.existsSync(filePath)) {
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
return splitNonEmptyLines(fs.readFileSync(filePath, "utf8")).length;
|
|
188
|
+
}
|
|
189
|
+
function splitNonEmptyLines(contents) {
|
|
190
|
+
return contents
|
|
191
|
+
.split("\n")
|
|
192
|
+
.map((line) => line.trim())
|
|
193
|
+
.filter((line) => line.length > 0);
|
|
194
|
+
}
|
|
195
|
+
function normalizeFinalText(lastAgentMessage, pendingFinalText) {
|
|
196
|
+
if (typeof lastAgentMessage === "string" && lastAgentMessage.trim()) {
|
|
197
|
+
return lastAgentMessage.trim();
|
|
198
|
+
}
|
|
199
|
+
return pendingFinalText?.trim() ?? "";
|
|
200
|
+
}
|
|
201
|
+
function normalizeTurnId(turnId) {
|
|
202
|
+
if (typeof turnId !== "string") {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const normalized = turnId.trim();
|
|
206
|
+
return normalized ? normalized : null;
|
|
207
|
+
}
|
|
208
|
+
function shouldCollectTurnScopedMessage(eventTurnId, targetTurnId, competingTurnStarted) {
|
|
209
|
+
if (!targetTurnId) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
if (eventTurnId) {
|
|
213
|
+
return eventTurnId === targetTurnId;
|
|
214
|
+
}
|
|
215
|
+
return !competingTurnStarted;
|
|
216
|
+
}
|
|
217
|
+
function joinMessageParts(commentaryMessages, finalText) {
|
|
218
|
+
return [...commentaryMessages, finalText]
|
|
219
|
+
.map((part) => part.trim())
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join("\n");
|
|
222
|
+
}
|
|
223
|
+
function extractMediaReferences(text) {
|
|
224
|
+
return parseQqMediaSegments(text)
|
|
225
|
+
.filter((segment) => segment.type === "media")
|
|
226
|
+
.map((segment) => segment.reference);
|
|
227
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const BetterSqlite3 = require("better-sqlite3");
|
|
7
|
+
export class CodexLocalSubmissionReader {
|
|
8
|
+
codexHomeDir;
|
|
9
|
+
sleep;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.codexHomeDir =
|
|
12
|
+
options.codexHomeDir ??
|
|
13
|
+
process.env.CODEX_HOME ??
|
|
14
|
+
path.join(os.homedir(), ".codex");
|
|
15
|
+
this.sleep =
|
|
16
|
+
options.sleep ??
|
|
17
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
18
|
+
}
|
|
19
|
+
captureCursorForThreadId(threadId) {
|
|
20
|
+
const normalizedThreadId = normalizeThreadId(threadId);
|
|
21
|
+
if (!normalizedThreadId) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const dbPath = this.resolveLogsDbPath();
|
|
25
|
+
if (!dbPath) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
let db = null;
|
|
29
|
+
try {
|
|
30
|
+
db = new BetterSqlite3(dbPath, {
|
|
31
|
+
readonly: true,
|
|
32
|
+
fileMustExist: true
|
|
33
|
+
});
|
|
34
|
+
const row = db
|
|
35
|
+
.prepare("SELECT MAX(id) AS lastLogId FROM logs")
|
|
36
|
+
.get();
|
|
37
|
+
return {
|
|
38
|
+
threadId: normalizedThreadId,
|
|
39
|
+
lastLogId: Number(row?.lastLogId ?? 0)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
db?.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async waitForTurnSubmission(cursor, options) {
|
|
50
|
+
const dbPath = this.resolveLogsDbPath();
|
|
51
|
+
if (!dbPath) {
|
|
52
|
+
return {
|
|
53
|
+
submitted: false,
|
|
54
|
+
turnId: null,
|
|
55
|
+
reason: "logs_db_missing"
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
for (let attempt = 0; attempt < options.pollAttempts; attempt += 1) {
|
|
59
|
+
const submission = readSubmissionSince(dbPath, cursor);
|
|
60
|
+
if (submission) {
|
|
61
|
+
return submission;
|
|
62
|
+
}
|
|
63
|
+
if (attempt + 1 < options.pollAttempts) {
|
|
64
|
+
await this.sleep(options.pollIntervalMs);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
submitted: false,
|
|
69
|
+
turnId: null,
|
|
70
|
+
reason: "submission_dispatch_not_observed"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
resolveLogsDbPath() {
|
|
74
|
+
const dbPath = path.join(this.codexHomeDir, "logs_2.sqlite");
|
|
75
|
+
return fs.existsSync(dbPath) ? dbPath : null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function readSubmissionSince(dbPath, cursor) {
|
|
79
|
+
let db = null;
|
|
80
|
+
try {
|
|
81
|
+
db = new BetterSqlite3(dbPath, {
|
|
82
|
+
readonly: true,
|
|
83
|
+
fileMustExist: true
|
|
84
|
+
});
|
|
85
|
+
const rows = db
|
|
86
|
+
.prepare(`SELECT id, target, feedback_log_body AS feedbackLogBody, thread_id AS threadId
|
|
87
|
+
FROM logs
|
|
88
|
+
WHERE id > ?
|
|
89
|
+
ORDER BY id ASC`)
|
|
90
|
+
.all(cursor.lastLogId);
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
cursor.lastLogId = rows.at(-1)?.id ?? cursor.lastLogId;
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
if (row.target !== "codex_client::transport") {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const body = typeof row.feedbackLogBody === "string" ? row.feedbackLogBody : "";
|
|
100
|
+
if (!body.includes("submission_dispatch")) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!matchesSubmissionThread(row.threadId, body, cursor.threadId)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
submitted: true,
|
|
108
|
+
turnId: extractTurnId(body),
|
|
109
|
+
reason: "submission_dispatch_logged"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return {
|
|
116
|
+
submitted: false,
|
|
117
|
+
turnId: null,
|
|
118
|
+
reason: "logs_db_unreadable"
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
db?.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function matchesSubmissionThread(rowThreadId, body, expectedThreadId) {
|
|
126
|
+
if (rowThreadId === expectedThreadId) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return body.includes(`thread.id=${expectedThreadId}`) || body.includes(`thread_id=${expectedThreadId}`);
|
|
130
|
+
}
|
|
131
|
+
function extractTurnId(body) {
|
|
132
|
+
const match = body.match(/turn(?:\.id|_id)=([^\s}]+)/);
|
|
133
|
+
if (!match?.[1]) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const turnId = match[1].trim();
|
|
137
|
+
return turnId ? turnId : null;
|
|
138
|
+
}
|
|
139
|
+
function normalizeThreadId(threadId) {
|
|
140
|
+
const normalized = threadId.trim();
|
|
141
|
+
return normalized ? normalized : null;
|
|
142
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { WeixinHttpClient } from "./weixin-http-client.js";
|
|
2
|
+
import { WeixinSender } from "./weixin-sender.js";
|
|
3
|
+
import { normalizeWeixinInboundMessage } from "./weixin-webhook.js";
|
|
4
|
+
export function createWeixinChannelAdapter(config) {
|
|
5
|
+
const apiClient = new WeixinHttpClient(config.egressBaseUrl, config.egressToken);
|
|
6
|
+
return {
|
|
7
|
+
webhook: {
|
|
8
|
+
routePath: config.webhookPath,
|
|
9
|
+
toInboundMessage: (payload) => normalizeWeixinInboundMessage(payload, {
|
|
10
|
+
accountKey: config.accountKey
|
|
11
|
+
})
|
|
12
|
+
},
|
|
13
|
+
egress: new WeixinSender(apiClient)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class WeixinHttpClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
token;
|
|
4
|
+
fetchFn;
|
|
5
|
+
constructor(baseUrl, token, options = {}) {
|
|
6
|
+
this.baseUrl = baseUrl;
|
|
7
|
+
this.token = token;
|
|
8
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
9
|
+
}
|
|
10
|
+
async sendTextMessage(target) {
|
|
11
|
+
return this.sendMessage({
|
|
12
|
+
peerId: target.peerId,
|
|
13
|
+
chatType: target.chatType,
|
|
14
|
+
content: target.content,
|
|
15
|
+
...(target.replyToMessageId ? { replyToMessageId: target.replyToMessageId } : {})
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async sendMessage(target) {
|
|
19
|
+
const response = await this.fetchFn(`${this.baseUrl}/messages`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
authorization: `Bearer ${this.token}`,
|
|
23
|
+
"content-type": "application/json"
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
...(target.accountKey ? { accountKey: target.accountKey } : {}),
|
|
27
|
+
...(target.accountId ? { accountId: target.accountId } : {}),
|
|
28
|
+
peerId: target.peerId,
|
|
29
|
+
chatType: target.chatType,
|
|
30
|
+
...(target.content ? { content: target.content } : {}),
|
|
31
|
+
...(target.mediaArtifacts?.length ? { mediaArtifacts: target.mediaArtifacts } : {}),
|
|
32
|
+
...(target.replyToMessageId ? { replyToMessageId: target.replyToMessageId } : {})
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const responseText = await response.text().catch(() => "");
|
|
37
|
+
throw new Error(`Weixin message send failed: ${response.status}${responseText ? ` ${responseText}` : ""}`);
|
|
38
|
+
}
|
|
39
|
+
const payload = (await response.json().catch(() => ({})));
|
|
40
|
+
return payload.id ?? null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { buildMediaArtifactFromReference, parseQqMediaSegments } from "../../qq/src/qq-media-parser.js";
|
|
2
|
+
const WEIXIN_TEXT_SEGMENT_MAX_LENGTH = 1800;
|
|
3
|
+
export class WeixinSender {
|
|
4
|
+
apiClient;
|
|
5
|
+
constructor(apiClient) {
|
|
6
|
+
this.apiClient = apiClient;
|
|
7
|
+
}
|
|
8
|
+
async deliver(draft) {
|
|
9
|
+
const providerMessageId = await this.deliverThroughApiClient(draft);
|
|
10
|
+
return {
|
|
11
|
+
jobId: draft.draftId,
|
|
12
|
+
sessionKey: draft.sessionKey,
|
|
13
|
+
providerMessageId,
|
|
14
|
+
deliveredAt: draft.createdAt
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async deliverThroughApiClient(draft) {
|
|
18
|
+
if (!this.apiClient) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const target = parseSessionTarget(draft.sessionKey);
|
|
22
|
+
const parsedArtifacts = parseQqMediaSegments(draft.text)
|
|
23
|
+
.filter((segment) => segment.type === "media")
|
|
24
|
+
.map((segment) => buildMediaArtifactFromReference(segment.reference));
|
|
25
|
+
const mediaArtifacts = dedupeArtifacts([...(draft.mediaArtifacts ?? []), ...parsedArtifacts]);
|
|
26
|
+
const content = extractTextContent(draft.text);
|
|
27
|
+
const segments = buildWeixinOutboundSegments({
|
|
28
|
+
peerId: target.peerId,
|
|
29
|
+
chatType: target.chatType,
|
|
30
|
+
accountKey: target.accountKey,
|
|
31
|
+
accountId: target.accountId,
|
|
32
|
+
content,
|
|
33
|
+
mediaArtifacts,
|
|
34
|
+
replyToMessageId: draft.replyToMessageId
|
|
35
|
+
});
|
|
36
|
+
let lastProviderMessageId = null;
|
|
37
|
+
for (const segment of segments) {
|
|
38
|
+
lastProviderMessageId = await this.apiClient.sendMessage(segment);
|
|
39
|
+
}
|
|
40
|
+
return lastProviderMessageId;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function buildWeixinOutboundSegments(input) {
|
|
44
|
+
const segments = [];
|
|
45
|
+
for (const contentSegment of splitWeixinTextContent(input.content)) {
|
|
46
|
+
segments.push({
|
|
47
|
+
peerId: input.peerId,
|
|
48
|
+
chatType: input.chatType,
|
|
49
|
+
accountKey: input.accountKey,
|
|
50
|
+
accountId: input.accountId,
|
|
51
|
+
content: contentSegment,
|
|
52
|
+
...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {})
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const artifact of input.mediaArtifacts) {
|
|
56
|
+
segments.push({
|
|
57
|
+
peerId: input.peerId,
|
|
58
|
+
chatType: input.chatType,
|
|
59
|
+
accountKey: input.accountKey,
|
|
60
|
+
accountId: input.accountId,
|
|
61
|
+
mediaArtifacts: [artifact],
|
|
62
|
+
...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {})
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return segments;
|
|
66
|
+
}
|
|
67
|
+
function splitWeixinTextContent(content, maxLength = WEIXIN_TEXT_SEGMENT_MAX_LENGTH) {
|
|
68
|
+
const text = content.trim();
|
|
69
|
+
if (!text) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
if (text.length <= maxLength) {
|
|
73
|
+
return [text];
|
|
74
|
+
}
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let current = "";
|
|
77
|
+
const flushCurrent = () => {
|
|
78
|
+
const trimmed = current.trim();
|
|
79
|
+
if (trimmed) {
|
|
80
|
+
chunks.push(trimmed);
|
|
81
|
+
}
|
|
82
|
+
current = "";
|
|
83
|
+
};
|
|
84
|
+
const appendPart = (part) => {
|
|
85
|
+
if (!part) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (current && current.length + part.length > maxLength) {
|
|
89
|
+
flushCurrent();
|
|
90
|
+
}
|
|
91
|
+
if (part.length <= maxLength) {
|
|
92
|
+
current += part;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const piece of splitOversizedPart(part, maxLength)) {
|
|
96
|
+
if (current && current.length + piece.length > maxLength) {
|
|
97
|
+
flushCurrent();
|
|
98
|
+
}
|
|
99
|
+
if (piece.length > maxLength) {
|
|
100
|
+
flushCurrent();
|
|
101
|
+
chunks.push(piece.trim());
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
current += piece;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
for (const part of splitIntoParagraphParts(text)) {
|
|
109
|
+
appendPart(part);
|
|
110
|
+
}
|
|
111
|
+
flushCurrent();
|
|
112
|
+
return chunks;
|
|
113
|
+
}
|
|
114
|
+
function splitIntoParagraphParts(text) {
|
|
115
|
+
const rawParts = text.split(/(\n{2,})/);
|
|
116
|
+
const parts = [];
|
|
117
|
+
for (let index = 0; index < rawParts.length; index += 2) {
|
|
118
|
+
const body = rawParts[index] ?? "";
|
|
119
|
+
const separator = rawParts[index + 1] ?? "";
|
|
120
|
+
if (body || separator) {
|
|
121
|
+
parts.push(`${body}${separator}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return parts;
|
|
125
|
+
}
|
|
126
|
+
function splitOversizedPart(part, maxLength) {
|
|
127
|
+
const lines = part.split(/(?<=\n)/);
|
|
128
|
+
const pieces = [];
|
|
129
|
+
let current = "";
|
|
130
|
+
const flushCurrent = () => {
|
|
131
|
+
if (current) {
|
|
132
|
+
pieces.push(current);
|
|
133
|
+
current = "";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
if (line.length > maxLength) {
|
|
138
|
+
flushCurrent();
|
|
139
|
+
pieces.push(...splitByLength(line, maxLength));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (current && current.length + line.length > maxLength) {
|
|
143
|
+
flushCurrent();
|
|
144
|
+
}
|
|
145
|
+
current += line;
|
|
146
|
+
}
|
|
147
|
+
flushCurrent();
|
|
148
|
+
return pieces;
|
|
149
|
+
}
|
|
150
|
+
function splitByLength(text, maxLength) {
|
|
151
|
+
const chars = Array.from(text);
|
|
152
|
+
const chunks = [];
|
|
153
|
+
for (let index = 0; index < chars.length; index += maxLength) {
|
|
154
|
+
chunks.push(chars.slice(index, index + maxLength).join(""));
|
|
155
|
+
}
|
|
156
|
+
return chunks;
|
|
157
|
+
}
|
|
158
|
+
function extractTextContent(text) {
|
|
159
|
+
const parts = parseQqMediaSegments(text)
|
|
160
|
+
.filter((segment) => segment.type === "text")
|
|
161
|
+
.map((segment) => segment.text)
|
|
162
|
+
.join("")
|
|
163
|
+
.trim();
|
|
164
|
+
return parts;
|
|
165
|
+
}
|
|
166
|
+
function dedupeArtifacts(artifacts) {
|
|
167
|
+
const seen = new Set();
|
|
168
|
+
const deduped = [];
|
|
169
|
+
for (const artifact of artifacts) {
|
|
170
|
+
const key = [
|
|
171
|
+
artifact.kind,
|
|
172
|
+
artifact.localPath || "",
|
|
173
|
+
artifact.sourceUrl || "",
|
|
174
|
+
artifact.originalName || ""
|
|
175
|
+
].join("::");
|
|
176
|
+
if (seen.has(key)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
seen.add(key);
|
|
180
|
+
deduped.push(artifact);
|
|
181
|
+
}
|
|
182
|
+
return deduped;
|
|
183
|
+
}
|
|
184
|
+
function parseSessionTarget(sessionKey) {
|
|
185
|
+
const parts = sessionKey.split("::");
|
|
186
|
+
const accountKey = parts.at(0) ?? "";
|
|
187
|
+
const scope = parts.at(-1) ?? "";
|
|
188
|
+
const segments = scope.split(":");
|
|
189
|
+
const chatType = segments.at(-2);
|
|
190
|
+
const peerId = segments.at(-1);
|
|
191
|
+
if (!accountKey || (chatType !== "c2c" && chatType !== "group") || !peerId) {
|
|
192
|
+
throw new Error(`Unable to parse channel session key: ${sessionKey}`);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
accountKey,
|
|
196
|
+
accountId: accountKey.startsWith("weixin:") ? accountKey.slice("weixin:".length) : accountKey,
|
|
197
|
+
chatType,
|
|
198
|
+
peerId
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function normalizeWeixinInboundMessage(payload, options) {
|
|
2
|
+
const chatType = payload.chatType === "group" ? "group" : "c2c";
|
|
3
|
+
const senderId = String(payload.senderId ?? "").trim();
|
|
4
|
+
const peerId = String(payload.peerId ?? senderId).trim();
|
|
5
|
+
const messageId = String(payload.messageId ?? "").trim();
|
|
6
|
+
const text = String(payload.text ?? "").trim();
|
|
7
|
+
const receivedAt = normalizeTimestamp(payload.receivedAt);
|
|
8
|
+
if (!senderId || !peerId || !messageId || !text) {
|
|
9
|
+
throw new Error("invalid weixin webhook payload");
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
messageId,
|
|
13
|
+
accountKey: options.accountKey,
|
|
14
|
+
sessionKey: buildSessionKey(options.accountKey, chatType, peerId),
|
|
15
|
+
peerKey: buildPeerKey(chatType, peerId),
|
|
16
|
+
chatType,
|
|
17
|
+
senderId,
|
|
18
|
+
text,
|
|
19
|
+
receivedAt
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function buildSessionKey(accountKey, chatType, peerId) {
|
|
23
|
+
return `${accountKey}::wx:${chatType}:${peerId}`;
|
|
24
|
+
}
|
|
25
|
+
function buildPeerKey(chatType, peerId) {
|
|
26
|
+
return `wx:${chatType}:${peerId}`;
|
|
27
|
+
}
|
|
28
|
+
function normalizeTimestamp(value) {
|
|
29
|
+
const input = String(value ?? "").trim();
|
|
30
|
+
if (!input) {
|
|
31
|
+
return new Date().toISOString();
|
|
32
|
+
}
|
|
33
|
+
const parsed = Date.parse(input);
|
|
34
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString();
|
|
35
|
+
}
|