doubao-cli 0.3.1 → 0.4.1
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/README.md +28 -2
- package/package.json +1 -1
- package/src/attachments.mjs +158 -0
- package/src/automation.mjs +196 -63
- package/src/cdp.mjs +1 -1
- package/src/cli.mjs +120 -13
package/README.md
CHANGED
|
@@ -24,10 +24,13 @@ doubao status
|
|
|
24
24
|
doubao profiles
|
|
25
25
|
doubao sessions list
|
|
26
26
|
doubao sessions current
|
|
27
|
+
doubao sessions create
|
|
28
|
+
doubao sessions create "summarize the attachment" --attach ./report.pdf --model pro --wait
|
|
27
29
|
doubao sessions open 38439138239851266
|
|
28
30
|
doubao sessions read 38439138239851266 --limit 5
|
|
29
31
|
doubao sessions send 38439138239851266 "hello"
|
|
30
32
|
doubao sessions send 38439138239851266 "hello" --wait
|
|
33
|
+
doubao sessions send 38439138239851266 "compare these files" --attach ./one.pdf --attach ./two.pdf --wait
|
|
31
34
|
doubao models
|
|
32
35
|
doubao model
|
|
33
36
|
doubao model select doubao-2.1-turbo
|
|
@@ -41,10 +44,31 @@ Every data-returning command supports `--json`. Select a non-default local profi
|
|
|
41
44
|
|
|
42
45
|
Message automation requires Doubao to be launched with local Chrome DevTools Protocol enabled:
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
```bash
|
|
48
|
+
doubao cdp launch
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If Doubao is already running without CDP, the command asks for confirmation before quitting it and relaunching with the debugging port enabled. Scripts and `--json` mode never prompt; pass `doubao cdp launch --yes` to confirm the restart explicitly. The command returns only after both the CDP endpoint and authenticated chat renderer are ready. The equivalent manual sequence is to quit Doubao completely and run `open -a /Applications/Doubao.app --args --remote-debugging-port=9225`.
|
|
45
52
|
|
|
46
53
|
Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
|
|
47
54
|
|
|
55
|
+
### New sessions and attachments
|
|
56
|
+
|
|
57
|
+
`sessions create` opens a clean composer. A numeric conversation id does not exist until the first message is sent, so `sessions create` without a message returns `conversationId: null`. Create and persist a session in one command by providing its first message:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
doubao sessions create "Start a new task" --model gpt-5.6-sol --wait --json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Attach one or more local files by repeating `--attach`. The CLI validates each path, transfers the file through the authenticated renderer, waits for Doubao to finish uploading it, and only then sends the message:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
doubao sessions create "Summarize these" --attach ./brief.pdf --attach ./notes.md --wait
|
|
67
|
+
doubao sessions send 38439138239851266 "Review this spreadsheet" --attach ./data.xlsx --wait
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Use `--` before message text that contains CLI option names, for example `doubao sessions create -- "Explain --model literally"`.
|
|
71
|
+
|
|
48
72
|
`models` reads the choices currently exposed by the desktop app. `model select` changes the active model, and `sessions send --model` selects a model before sending.
|
|
49
73
|
|
|
50
74
|
| Model | Value | Short aliases |
|
|
@@ -66,12 +90,14 @@ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normal
|
|
|
66
90
|
- The current session is recovered from Chromium's local session store.
|
|
67
91
|
- Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
|
|
68
92
|
- Model discovery and selection use the renderer's semantic menu attributes and native CDP input events.
|
|
93
|
+
- New sessions use Doubao's blank `/chat` route and return the id assigned after the first confirmed send.
|
|
94
|
+
- Attachments are transferred into the renderer through its drop-upload path; file contents and credentials are never printed.
|
|
69
95
|
|
|
70
96
|
No hard-coded UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
|
|
71
97
|
|
|
72
98
|
## Limits
|
|
73
99
|
|
|
74
|
-
Message read/send
|
|
100
|
+
Message read/send and attachment upload use stable DOM test ids in the authenticated Doubao renderer over localhost CDP. A Doubao update can change these selectors. The CLI verifies that uploads finish and the exact user message appears in the target conversation before reporting success. The CLI currently accepts up to 50 attachments per command and files up to 100 MiB each; the Doubao service can impose stricter type or size limits.
|
|
75
101
|
|
|
76
102
|
## Development
|
|
77
103
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DROP_AREA = '[data-testid="file_drop_area"]';
|
|
5
|
+
const ATTACHMENT_ITEM = '[data-testid="attachment_area"] [data-testid="attachment_file_item"]';
|
|
6
|
+
const MAX_FILE_COUNT = 50;
|
|
7
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
const CHUNK_BYTES = 384 * 1024;
|
|
9
|
+
|
|
10
|
+
const MIME_TYPES = new Map([
|
|
11
|
+
['.csv', 'text/csv'],
|
|
12
|
+
['.doc', 'application/msword'],
|
|
13
|
+
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
14
|
+
['.epub', 'application/epub+zip'],
|
|
15
|
+
['.gif', 'image/gif'],
|
|
16
|
+
['.jpeg', 'image/jpeg'],
|
|
17
|
+
['.jpg', 'image/jpeg'],
|
|
18
|
+
['.json', 'application/json'],
|
|
19
|
+
['.md', 'text/markdown'],
|
|
20
|
+
['.mobi', 'application/x-mobipocket-ebook'],
|
|
21
|
+
['.pdf', 'application/pdf'],
|
|
22
|
+
['.png', 'image/png'],
|
|
23
|
+
['.ppt', 'application/vnd.ms-powerpoint'],
|
|
24
|
+
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
|
25
|
+
['.txt', 'text/plain'],
|
|
26
|
+
['.webp', 'image/webp'],
|
|
27
|
+
['.xls', 'application/vnd.ms-excel'],
|
|
28
|
+
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function delay(milliseconds) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function attachmentMimeType(filePath) {
|
|
36
|
+
return MIME_TYPES.get(path.extname(filePath).toLocaleLowerCase('en-US')) || 'application/octet-stream';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function resolveAttachmentFiles(filePaths) {
|
|
40
|
+
if (!Array.isArray(filePaths) || !filePaths.length) return [];
|
|
41
|
+
if (filePaths.length > MAX_FILE_COUNT) throw new Error(`at most ${MAX_FILE_COUNT} attachments can be uploaded at once`);
|
|
42
|
+
|
|
43
|
+
const files = [];
|
|
44
|
+
for (const input of filePaths) {
|
|
45
|
+
const resolvedPath = path.resolve(input);
|
|
46
|
+
let stat;
|
|
47
|
+
try {
|
|
48
|
+
stat = await fs.stat(resolvedPath);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`attachment does not exist: ${resolvedPath}`);
|
|
51
|
+
}
|
|
52
|
+
if (!stat.isFile()) throw new Error(`attachment is not a regular file: ${resolvedPath}`);
|
|
53
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error(`attachment exceeds the 100 MiB CLI limit: ${resolvedPath}`);
|
|
54
|
+
files.push({
|
|
55
|
+
path: resolvedPath,
|
|
56
|
+
name: path.basename(resolvedPath),
|
|
57
|
+
size: stat.size,
|
|
58
|
+
type: attachmentMimeType(resolvedPath),
|
|
59
|
+
lastModified: Math.trunc(stat.mtimeMs),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function stageFiles(client, files, stateKey) {
|
|
66
|
+
await client.evaluate(`globalThis[${JSON.stringify(stateKey)}] = []`);
|
|
67
|
+
for (const [index, file] of files.entries()) {
|
|
68
|
+
await client.evaluate(`globalThis[${JSON.stringify(stateKey)}].push({
|
|
69
|
+
name: ${JSON.stringify(file.name)},
|
|
70
|
+
type: ${JSON.stringify(file.type)},
|
|
71
|
+
lastModified: ${file.lastModified},
|
|
72
|
+
chunks: [],
|
|
73
|
+
})`);
|
|
74
|
+
const bytes = await fs.readFile(file.path);
|
|
75
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
|
|
76
|
+
const base64 = bytes.subarray(offset, offset + CHUNK_BYTES).toString('base64');
|
|
77
|
+
await client.evaluate(`globalThis[${JSON.stringify(stateKey)}][${index}].chunks.push(
|
|
78
|
+
Uint8Array.from(atob(${JSON.stringify(base64)}), (character) => character.charCodeAt(0)),
|
|
79
|
+
)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function dispatchDrop(client, stateKey) {
|
|
85
|
+
return client.evaluate(`(() => {
|
|
86
|
+
const records = globalThis[${JSON.stringify(stateKey)}];
|
|
87
|
+
const target = document.querySelector(${JSON.stringify(DROP_AREA)})?.parentElement;
|
|
88
|
+
if (!target) throw new Error('Doubao attachment drop target was not found');
|
|
89
|
+
const transfer = new DataTransfer();
|
|
90
|
+
for (const record of records) {
|
|
91
|
+
transfer.items.add(new File(record.chunks, record.name, {
|
|
92
|
+
type: record.type,
|
|
93
|
+
lastModified: record.lastModified,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer }));
|
|
97
|
+
return [...transfer.files].map((file) => ({ name: file.name, size: file.size, type: file.type }));
|
|
98
|
+
})()`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function waitForUploads(client, expectedCount, timeoutMs) {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const state = await client.evaluate(`(() => {
|
|
105
|
+
const items = [...document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)})].slice(-${expectedCount});
|
|
106
|
+
return items.map((item) => ({
|
|
107
|
+
available: item.getAttribute('data-available') === 'true',
|
|
108
|
+
name: (item.querySelector('[data-testid="message_nested_content_file_name"]')?.innerText || '').trim(),
|
|
109
|
+
status: (item.querySelector('[data-testid="message_nested_content_file_subtitle"]')?.innerText || '').trim(),
|
|
110
|
+
}));
|
|
111
|
+
})()`);
|
|
112
|
+
if (state.length === expectedCount && state.every((item) => item.available)) return state;
|
|
113
|
+
const failed = state.find((item) => /(?:失败|不支持|超出|error|failed)/iu.test(item.status));
|
|
114
|
+
if (failed) throw new Error(`Doubao failed to upload attachment ${failed.name || ''}: ${failed.status}`.trim());
|
|
115
|
+
await delay(250);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Doubao attachments did not finish uploading within ${timeoutMs} ms`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function clearComposerAttachments(client) {
|
|
121
|
+
await client.evaluate(`(() => {
|
|
122
|
+
for (const item of document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)})) {
|
|
123
|
+
item.querySelector('[data-testid="message_nested_content_file_delete"]')
|
|
124
|
+
?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
125
|
+
}
|
|
126
|
+
})()`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function uploadAttachmentsFromClient(client, filePaths, options = {}) {
|
|
130
|
+
const files = await resolveAttachmentFiles(filePaths);
|
|
131
|
+
if (!files.length) return [];
|
|
132
|
+
const existingAttachmentCount = await client.evaluate(`document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)}).length`);
|
|
133
|
+
if (existingAttachmentCount) {
|
|
134
|
+
throw new Error('Doubao composer already contains draft attachments; remove them before using --attach');
|
|
135
|
+
}
|
|
136
|
+
const timeoutMs = options.timeoutMs || 60_000;
|
|
137
|
+
const stateKey = `__doubaoCliUpload_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
138
|
+
try {
|
|
139
|
+
await stageFiles(client, files, stateKey);
|
|
140
|
+
const dropped = await dispatchDrop(client, stateKey);
|
|
141
|
+
if (dropped.length !== files.length) throw new Error('Doubao did not accept every attachment from the drop event');
|
|
142
|
+
let uploaded;
|
|
143
|
+
try {
|
|
144
|
+
uploaded = await waitForUploads(client, files.length, timeoutMs);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await clearComposerAttachments(client).catch(() => {});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return uploaded.map((item, index) => ({
|
|
150
|
+
name: item.name || files[index].name,
|
|
151
|
+
path: files[index].path,
|
|
152
|
+
size: files[index].size,
|
|
153
|
+
type: files[index].type,
|
|
154
|
+
}));
|
|
155
|
+
} finally {
|
|
156
|
+
await client.evaluate(`delete globalThis[${JSON.stringify(stateKey)}]`).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/automation.mjs
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { uploadAttachmentsFromClient } from './attachments.mjs';
|
|
2
3
|
import { withChatClient } from './cdp.mjs';
|
|
3
4
|
import { selectModelFromClient } from './models.mjs';
|
|
4
5
|
|
|
5
6
|
const CHAT_INPUT = '[data-testid="chat_input_input"] [contenteditable="true"]';
|
|
6
7
|
const SEND_BUTTON = '[data-testid="chat_input_send_button"]';
|
|
8
|
+
const CREATE_BUTTON = '[data-testid="create_conversation_button"]';
|
|
9
|
+
const CREATE_OFFICE_TASK = '[data-testid="create_office_task_button"]';
|
|
7
10
|
|
|
8
11
|
function delay(milliseconds) {
|
|
9
12
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
function remainingMilliseconds(deadline, timeoutMs) {
|
|
16
|
+
const remaining = deadline - Date.now();
|
|
17
|
+
if (remaining <= 0) throw new Error(`Doubao operation did not complete within ${timeoutMs} ms`);
|
|
18
|
+
return remaining;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export function conversationDeepLink(id) {
|
|
13
22
|
const webUrl = `https://www.doubao.com/chat/${id}`;
|
|
14
23
|
return `doubao://doubaoapp/open-url?url=${encodeURIComponent(webUrl)}`;
|
|
@@ -21,6 +30,10 @@ export function openConversation(id) {
|
|
|
21
30
|
return url;
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
export function conversationIdFromUrl(url) {
|
|
34
|
+
return /\/chat\/(\d{12,24})(?:[?#]|$)/u.exec(url || '')?.[1] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
async function waitForConversation(client, id, timeoutMs) {
|
|
25
38
|
const deadline = Date.now() + timeoutMs;
|
|
26
39
|
while (Date.now() < deadline) {
|
|
@@ -31,6 +44,29 @@ async function waitForConversation(client, id, timeoutMs) {
|
|
|
31
44
|
throw new Error(`Doubao did not open conversation ${id} within ${timeoutMs} ms`);
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
async function waitForBlankConversation(client, timeoutMs) {
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
const state = await client.evaluate(`({
|
|
51
|
+
href: location.href,
|
|
52
|
+
ready: Boolean(document.querySelector(${JSON.stringify(CHAT_INPUT)})),
|
|
53
|
+
})`);
|
|
54
|
+
if (state?.ready && /\/chat(?:[?#]|$)/u.test(state.href)) return state.href;
|
|
55
|
+
await delay(100);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Doubao did not open a blank conversation within ${timeoutMs} ms`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function waitForConversationId(client, timeoutMs) {
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
const id = conversationIdFromUrl(await client.evaluate('location.href'));
|
|
64
|
+
if (id) return id;
|
|
65
|
+
await delay(100);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Doubao did not assign a conversation id within ${timeoutMs} ms`);
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-testid="union_message"]')]
|
|
35
71
|
.map((element) => {
|
|
36
72
|
const role = element.querySelector('[data-testid="send_message"]')
|
|
@@ -39,7 +75,12 @@ const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-te
|
|
|
39
75
|
const parts = [...element.querySelectorAll('[data-testid="message_text_content"]')]
|
|
40
76
|
.map((part) => (part.innerText || '').trim())
|
|
41
77
|
.filter(Boolean);
|
|
42
|
-
|
|
78
|
+
const attachments = [...element.querySelectorAll('[data-testid="message_nested_content_file_name"]')]
|
|
79
|
+
.map((part) => (part.innerText || '').trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
return role && (parts.length || attachments.length)
|
|
82
|
+
? { role, text: parts.join('\\n'), ...(attachments.length ? { attachments } : {}) }
|
|
83
|
+
: null;
|
|
43
84
|
})
|
|
44
85
|
.filter(Boolean))()`;
|
|
45
86
|
|
|
@@ -55,6 +96,27 @@ export function replyAfterLastUserMessage(messages, message) {
|
|
|
55
96
|
return null;
|
|
56
97
|
}
|
|
57
98
|
|
|
99
|
+
function attachmentCounts(messages) {
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const item of messages) {
|
|
102
|
+
if (item.role !== 'user') continue;
|
|
103
|
+
for (const name of item.attachments || []) counts.set(name, (counts.get(name) || 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
return counts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function attachmentsConfirmed(before, after, attachments) {
|
|
109
|
+
const beforeCounts = attachmentCounts(before);
|
|
110
|
+
const afterCounts = attachmentCounts(after);
|
|
111
|
+
const expectedCounts = new Map();
|
|
112
|
+
for (const attachment of attachments) {
|
|
113
|
+
expectedCounts.set(attachment.name, (expectedCounts.get(attachment.name) || 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
return [...expectedCounts].every(([name, expected]) => (
|
|
116
|
+
(afterCounts.get(name) || 0) - (beforeCounts.get(name) || 0) >= expected
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
|
|
58
120
|
export async function readConversation(id, options = {}) {
|
|
59
121
|
const timeoutMs = options.timeoutMs || 10_000;
|
|
60
122
|
openConversation(id);
|
|
@@ -65,75 +127,146 @@ export async function readConversation(id, options = {}) {
|
|
|
65
127
|
});
|
|
66
128
|
}
|
|
67
129
|
|
|
68
|
-
|
|
69
|
-
const timeoutMs = options.timeoutMs || 120_000;
|
|
70
|
-
const waitForReply = options.waitForReply || false;
|
|
130
|
+
function validateMessage(message) {
|
|
71
131
|
if (typeof message !== 'string' || !message.trim()) throw new Error('message cannot be empty');
|
|
72
132
|
if (message.length > 100_000) throw new Error('message exceeds the 100000 character limit');
|
|
133
|
+
}
|
|
73
134
|
|
|
74
|
-
|
|
75
|
-
return
|
|
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
|
-
|
|
135
|
+
function publicAttachments(attachments) {
|
|
136
|
+
return attachments.map(({ name, size, type }) => ({ name, size, type }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function prepareComposer(client, options, timeoutMs) {
|
|
140
|
+
const selectedModel = options.model ? await selectModelFromClient(client, options.model) : null;
|
|
141
|
+
const attachments = options.attachments?.length
|
|
142
|
+
? await uploadAttachmentsFromClient(client, options.attachments, { timeoutMs: Math.min(timeoutMs, 60_000) })
|
|
143
|
+
: [];
|
|
144
|
+
return { selectedModel, attachments };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function sendFromClient(client, requestedId, message, options, prepared) {
|
|
148
|
+
const { selectedModel, attachments } = prepared;
|
|
149
|
+
const timeoutMs = options.timeoutMs || 120_000;
|
|
150
|
+
const waitForReply = options.waitForReply || false;
|
|
151
|
+
const before = await readFromClient(client);
|
|
152
|
+
const matchingUserCountBefore = before.filter((item) => item.role === 'user' && item.text === message).length;
|
|
153
|
+
const encodedMessage = JSON.stringify(message);
|
|
154
|
+
|
|
155
|
+
const draft = await client.evaluate(`(async () => {
|
|
156
|
+
const editor = document.querySelector(${JSON.stringify(CHAT_INPUT)});
|
|
157
|
+
if (!editor) throw new Error('Doubao message editor was not found');
|
|
158
|
+
editor.focus();
|
|
159
|
+
document.execCommand('selectAll', false, null);
|
|
160
|
+
document.execCommand('insertText', false, ${encodedMessage});
|
|
161
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
162
|
+
const button = document.querySelector(${JSON.stringify(SEND_BUTTON)});
|
|
163
|
+
if (!button || button.disabled) throw new Error('Doubao send button is unavailable');
|
|
164
|
+
const text = (editor.innerText || '').replace(/\\n$/, '');
|
|
165
|
+
button.click();
|
|
166
|
+
return text;
|
|
167
|
+
})()`);
|
|
168
|
+
if (draft !== message) throw new Error('Doubao editor did not accept the complete message');
|
|
169
|
+
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
let messages = before;
|
|
172
|
+
while (Date.now() < deadline) {
|
|
173
|
+
messages = await readFromClient(client);
|
|
174
|
+
const matchingUserCount = messages.filter((item) => item.role === 'user' && item.text === message).length;
|
|
175
|
+
if (matchingUserCount > matchingUserCountBefore) break;
|
|
176
|
+
await delay(250);
|
|
177
|
+
}
|
|
178
|
+
const matchingUserMessages = messages.filter((item) => item.role === 'user' && item.text === message);
|
|
179
|
+
const sent = matchingUserMessages.length > matchingUserCountBefore ? matchingUserMessages.at(-1) : null;
|
|
180
|
+
if (!sent) throw new Error(`Doubao did not confirm a new sent message within ${timeoutMs} ms`);
|
|
181
|
+
|
|
182
|
+
if (attachments.length) {
|
|
183
|
+
while (Date.now() < deadline && !attachmentsConfirmed(before, messages, attachments)) {
|
|
103
184
|
await delay(250);
|
|
185
|
+
messages = await readFromClient(client);
|
|
104
186
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!sent) throw new Error(`Doubao did not confirm a new sent message within ${timeoutMs} ms`);
|
|
108
|
-
|
|
109
|
-
if (!waitForReply) {
|
|
110
|
-
return { conversationId: id, ...(selectedModel ? { model: selectedModel.name } : {}), sent, reply: null };
|
|
187
|
+
if (!attachmentsConfirmed(before, messages, attachments)) {
|
|
188
|
+
throw new Error(`Doubao did not confirm the sent attachments within ${timeoutMs} ms`);
|
|
111
189
|
}
|
|
190
|
+
}
|
|
112
191
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
192
|
+
const conversationId = requestedId || await waitForConversationId(client, Math.min(5000, Math.max(1, deadline - Date.now())));
|
|
193
|
+
const baseResult = {
|
|
194
|
+
conversationId,
|
|
195
|
+
...(selectedModel ? { model: selectedModel.name } : {}),
|
|
196
|
+
...(attachments.length ? { attachments: publicAttachments(attachments) } : {}),
|
|
197
|
+
sent,
|
|
198
|
+
};
|
|
199
|
+
if (!waitForReply) return { ...baseResult, reply: null };
|
|
200
|
+
|
|
201
|
+
let stableText = '';
|
|
202
|
+
let stablePolls = 0;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
messages = await readFromClient(client);
|
|
205
|
+
const reply = replyAfterLastUserMessage(messages, message);
|
|
206
|
+
const generating = await client.evaluate(`[
|
|
207
|
+
...document.querySelectorAll('[data-testid="chat_input_local_break_button"], [data-testid="chat_input_end_button"]'),
|
|
208
|
+
].some((element) => {
|
|
209
|
+
const style = getComputedStyle(element);
|
|
210
|
+
const rect = element.getBoundingClientRect();
|
|
211
|
+
return style.display !== 'none'
|
|
212
|
+
&& style.visibility !== 'hidden'
|
|
213
|
+
&& Number(style.opacity) !== 0
|
|
214
|
+
&& rect.width > 0
|
|
215
|
+
&& rect.height > 0;
|
|
216
|
+
})`);
|
|
217
|
+
if (reply?.text && reply.text === stableText && !generating) stablePolls += 1;
|
|
218
|
+
else stablePolls = 0;
|
|
219
|
+
stableText = reply?.text || '';
|
|
220
|
+
if (reply && stablePolls >= 2) return { ...baseResult, reply };
|
|
221
|
+
await delay(500);
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`Doubao reply did not complete within ${timeoutMs} ms`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function sendMessage(id, message, options = {}) {
|
|
227
|
+
const timeoutMs = options.timeoutMs || 120_000;
|
|
228
|
+
const deadline = Date.now() + timeoutMs;
|
|
229
|
+
validateMessage(message);
|
|
230
|
+
|
|
231
|
+
openConversation(id);
|
|
232
|
+
return withChatClient(async (client) => {
|
|
233
|
+
await waitForConversation(client, id, Math.min(remainingMilliseconds(deadline, timeoutMs), 15_000));
|
|
234
|
+
const prepared = await prepareComposer(client, options, remainingMilliseconds(deadline, timeoutMs));
|
|
235
|
+
return sendFromClient(client, id, message, {
|
|
236
|
+
...options,
|
|
237
|
+
timeoutMs: remainingMilliseconds(deadline, timeoutMs),
|
|
238
|
+
}, prepared);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function createConversation(message, options = {}) {
|
|
243
|
+
const timeoutMs = options.timeoutMs || 120_000;
|
|
244
|
+
const deadline = Date.now() + timeoutMs;
|
|
245
|
+
const hasMessage = typeof message === 'string' && message.length > 0;
|
|
246
|
+
if (hasMessage) validateMessage(message);
|
|
247
|
+
|
|
248
|
+
return withChatClient(async (client) => {
|
|
249
|
+
const createSelector = await client.evaluate(`document.querySelector(${JSON.stringify(CREATE_BUTTON)})
|
|
250
|
+
? ${JSON.stringify(CREATE_BUTTON)}
|
|
251
|
+
: document.querySelector(${JSON.stringify(CREATE_OFFICE_TASK)}) ? ${JSON.stringify(CREATE_OFFICE_TASK)} : null`);
|
|
252
|
+
if (!createSelector) throw new Error('Doubao new conversation button was not found');
|
|
253
|
+
await client.click(createSelector);
|
|
254
|
+
const route = await waitForBlankConversation(client, Math.min(remainingMilliseconds(deadline, timeoutMs), 15_000));
|
|
255
|
+
const prepared = await prepareComposer(client, options, remainingMilliseconds(deadline, timeoutMs));
|
|
256
|
+
if (!hasMessage) {
|
|
257
|
+
return {
|
|
258
|
+
conversationId: null,
|
|
259
|
+
created: true,
|
|
260
|
+
persisted: false,
|
|
261
|
+
route,
|
|
262
|
+
...(prepared.selectedModel ? { model: prepared.selectedModel.name } : {}),
|
|
263
|
+
...(prepared.attachments.length ? { attachments: publicAttachments(prepared.attachments) } : {}),
|
|
264
|
+
};
|
|
136
265
|
}
|
|
137
|
-
|
|
266
|
+
const result = await sendFromClient(client, null, message, {
|
|
267
|
+
...options,
|
|
268
|
+
timeoutMs: remainingMilliseconds(deadline, timeoutMs),
|
|
269
|
+
}, prepared);
|
|
270
|
+
return { ...result, created: true, persisted: true };
|
|
138
271
|
});
|
|
139
272
|
}
|
package/src/cdp.mjs
CHANGED
|
@@ -140,7 +140,7 @@ export class CdpClient {
|
|
|
140
140
|
export async function withChatClient(callback, endpoint = cdpEndpoint()) {
|
|
141
141
|
const status = await cdpStatus(endpoint);
|
|
142
142
|
if (!status.available) {
|
|
143
|
-
throw new Error(`Doubao CDP is unavailable at ${endpoint}.
|
|
143
|
+
throw new Error(`Doubao CDP is unavailable at ${endpoint}. Run "doubao cdp launch" to restart Doubao with CDP enabled.`);
|
|
144
144
|
}
|
|
145
145
|
const target = await findChatTarget(endpoint);
|
|
146
146
|
const client = await new CdpClient(target.webSocketDebuggerUrl).connect();
|
package/src/cli.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
5
|
import { currentSession, getDataDir, listSessions, resolveProfile } from './storage.mjs';
|
|
5
6
|
import { cdpStatus } from './cdp.mjs';
|
|
6
|
-
import { openConversation, readConversation, sendMessage } from './automation.mjs';
|
|
7
|
+
import { createConversation, openConversation, readConversation, sendMessage } from './automation.mjs';
|
|
7
8
|
import { currentModel, listModels, selectModel } from './models.mjs';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_APP = '/Applications/Doubao.app';
|
|
@@ -14,14 +15,15 @@ const HELP = `Usage:
|
|
|
14
15
|
doubao profiles [--json]
|
|
15
16
|
doubao sessions list [--profile <name>] [--json]
|
|
16
17
|
doubao sessions current [--profile <name>] [--json]
|
|
18
|
+
doubao sessions create [message] [--attach <path>] [--model <model>] [--wait] [--timeout <seconds>] [--json]
|
|
17
19
|
doubao sessions open <conversation-id>
|
|
18
20
|
doubao sessions read <conversation-id> [--limit <count>] [--json]
|
|
19
|
-
doubao sessions send <conversation-id> <message> [--model <model>] [--wait] [--timeout <seconds>] [--json]
|
|
21
|
+
doubao sessions send <conversation-id> <message> [--attach <path>] [--model <model>] [--wait] [--timeout <seconds>] [--json]
|
|
20
22
|
doubao models [--json]
|
|
21
23
|
doubao model [--json]
|
|
22
24
|
doubao model select <model> [--json]
|
|
23
25
|
doubao cdp status [--json]
|
|
24
|
-
doubao cdp launch [--json]
|
|
26
|
+
doubao cdp launch [--yes] [--json]
|
|
25
27
|
doubao capabilities [--json]
|
|
26
28
|
|
|
27
29
|
Environment:
|
|
@@ -30,17 +32,24 @@ Environment:
|
|
|
30
32
|
DOUBAO_CDP_ENDPOINT CDP endpoint (default: http://127.0.0.1:9225)
|
|
31
33
|
`;
|
|
32
34
|
|
|
33
|
-
function parseOptions(argv) {
|
|
35
|
+
export function parseOptions(argv) {
|
|
34
36
|
const args = [];
|
|
35
37
|
let profile;
|
|
36
38
|
let json = false;
|
|
39
|
+
let yes = false;
|
|
37
40
|
let wait = false;
|
|
38
41
|
let timeoutSeconds = 120;
|
|
39
42
|
let limit = 20;
|
|
40
43
|
let model;
|
|
44
|
+
const attachments = [];
|
|
41
45
|
for (let index = 0; index < argv.length; index += 1) {
|
|
42
|
-
if (argv[index] === '--
|
|
46
|
+
if (argv[index] === '--') {
|
|
47
|
+
args.push(...argv.slice(index + 1));
|
|
48
|
+
break;
|
|
49
|
+
} else if (argv[index] === '--json') {
|
|
43
50
|
json = true;
|
|
51
|
+
} else if (argv[index] === '--yes') {
|
|
52
|
+
yes = true;
|
|
44
53
|
} else if (argv[index] === '--wait') {
|
|
45
54
|
wait = true;
|
|
46
55
|
} else if (argv[index] === '--profile') {
|
|
@@ -59,11 +68,16 @@ function parseOptions(argv) {
|
|
|
59
68
|
model = argv[index + 1];
|
|
60
69
|
if (!model) throw new Error('--model requires a value');
|
|
61
70
|
index += 1;
|
|
71
|
+
} else if (argv[index] === '--attach') {
|
|
72
|
+
const attachment = argv[index + 1];
|
|
73
|
+
if (!attachment || attachment.startsWith('--')) throw new Error('--attach requires a file path');
|
|
74
|
+
attachments.push(attachment);
|
|
75
|
+
index += 1;
|
|
62
76
|
} else {
|
|
63
77
|
args.push(argv[index]);
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
|
-
return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit, model };
|
|
80
|
+
return { args, profile, json, yes, wait, timeoutMs: timeoutSeconds * 1000, limit, model, attachments };
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
function output(value, json) {
|
|
@@ -79,9 +93,71 @@ function appVersion(appPath) {
|
|
|
79
93
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
function
|
|
96
|
+
function appProcessPattern(appPath) {
|
|
83
97
|
const executable = path.join(appPath, 'Contents', 'MacOS', 'Doubao');
|
|
84
|
-
|
|
98
|
+
const escaped = executable.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
99
|
+
return `^${escaped}([[:space:]]|$)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function appRunning(appPath) {
|
|
103
|
+
return spawnSync('/usr/bin/pgrep', ['-f', appProcessPattern(appPath)]).status === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function waitForAppExit(appPath, timeoutMs) {
|
|
107
|
+
const deadline = Date.now() + timeoutMs;
|
|
108
|
+
while (Date.now() < deadline) {
|
|
109
|
+
if (!appRunning(appPath)) return true;
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
111
|
+
}
|
|
112
|
+
return !appRunning(appPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function quitAppForCdp(appPath) {
|
|
116
|
+
const result = spawnSync('/usr/bin/osascript', [
|
|
117
|
+
'-e',
|
|
118
|
+
'tell application id "com.bot.pc.doubao" to quit',
|
|
119
|
+
], { encoding: 'utf8' });
|
|
120
|
+
if (result.status === 0 && await waitForAppExit(appPath, 5000)) return;
|
|
121
|
+
|
|
122
|
+
const terminated = spawnSync('/usr/bin/pkill', ['-TERM', '-f', appProcessPattern(appPath)], { encoding: 'utf8' });
|
|
123
|
+
if (terminated.status !== 0 && terminated.status !== 1) {
|
|
124
|
+
throw new Error(terminated.stderr.trim() || result.stderr.trim() || 'failed to stop Doubao before enabling CDP');
|
|
125
|
+
}
|
|
126
|
+
if (!await waitForAppExit(appPath, 10_000)) {
|
|
127
|
+
throw new Error('Doubao did not quit after SIGTERM. Quit it manually, then run "doubao cdp launch" again.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function confirmCdpRestart({ json, yes }) {
|
|
132
|
+
if (yes) return;
|
|
133
|
+
if (json || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
134
|
+
throw new Error('Doubao must restart to enable CDP. Re-run "doubao cdp launch --yes" to confirm.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
138
|
+
try {
|
|
139
|
+
const answer = await prompt.question('Doubao must restart to enable CDP. Continue? [y/N] ');
|
|
140
|
+
if (!/^(?:y|yes)$/iu.test(answer.trim())) {
|
|
141
|
+
throw new Error('CDP launch cancelled; Doubao was not restarted.');
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
prompt.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function waitForAutomationReady(timeoutMs = 20_000) {
|
|
149
|
+
const deadline = Date.now() + timeoutMs;
|
|
150
|
+
let lastError;
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await listModels();
|
|
154
|
+
if (result.models.length) return;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
lastError = error;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Doubao CDP is listening, but the chat renderer is not ready: ${lastError?.message || 'timed out'}`);
|
|
85
161
|
}
|
|
86
162
|
|
|
87
163
|
function validateId(value) {
|
|
@@ -94,7 +170,7 @@ function sessionWithTitle(profilePath, id) {
|
|
|
94
170
|
}
|
|
95
171
|
|
|
96
172
|
export async function main(argv) {
|
|
97
|
-
const { args, profile: requestedProfile, json, wait, timeoutMs, limit, model } = parseOptions(argv);
|
|
173
|
+
const { args, profile: requestedProfile, json, yes, wait, timeoutMs, limit, model, attachments } = parseOptions(argv);
|
|
98
174
|
const [command, subcommand, operand] = args;
|
|
99
175
|
const dataDir = getDataDir();
|
|
100
176
|
|
|
@@ -127,8 +203,10 @@ export async function main(argv) {
|
|
|
127
203
|
listSessions: true,
|
|
128
204
|
detectCurrentSession: true,
|
|
129
205
|
openSession: true,
|
|
206
|
+
createSessions: cdp.available,
|
|
130
207
|
readMessages: cdp.available,
|
|
131
208
|
sendMessages: cdp.available,
|
|
209
|
+
uploadAttachments: cdp.available,
|
|
132
210
|
selectModels: cdp.available,
|
|
133
211
|
cdp,
|
|
134
212
|
note: cdp.available
|
|
@@ -141,8 +219,10 @@ export async function main(argv) {
|
|
|
141
219
|
console.log('sessions list\tyes');
|
|
142
220
|
console.log('sessions current\tyes');
|
|
143
221
|
console.log('sessions open\tyes');
|
|
222
|
+
console.log(`sessions create\t${capabilities.createSessions ? 'yes' : 'no'}`);
|
|
144
223
|
console.log(`messages read\t${capabilities.readMessages ? 'yes' : 'no'}`);
|
|
145
224
|
console.log(`messages send\t${capabilities.sendMessages ? 'yes' : 'no'}`);
|
|
225
|
+
console.log(`attachments upload\t${capabilities.uploadAttachments ? 'yes' : 'no'}`);
|
|
146
226
|
console.log(`models select\t${capabilities.selectModels ? 'yes' : 'no'}`);
|
|
147
227
|
console.log(`note\t${capabilities.note}`);
|
|
148
228
|
}
|
|
@@ -199,17 +279,22 @@ export async function main(argv) {
|
|
|
199
279
|
if (command === 'cdp' && subcommand === 'launch') {
|
|
200
280
|
const existing = await cdpStatus();
|
|
201
281
|
if (existing.available) {
|
|
282
|
+
await waitForAutomationReady();
|
|
202
283
|
if (json) output(existing, true);
|
|
203
284
|
else console.log(`available\tyes\nendpoint\t${existing.endpoint}`);
|
|
204
285
|
return;
|
|
205
286
|
}
|
|
206
287
|
const appPath = process.env.DOUBAO_APP || DEFAULT_APP;
|
|
207
|
-
if (appRunning(appPath)) throw new Error('Doubao is already running without CDP. Quit it completely, then run this command again.');
|
|
208
288
|
const endpoint = new URL(existing.endpoint);
|
|
209
289
|
if (endpoint.hostname !== '127.0.0.1' && endpoint.hostname !== 'localhost') {
|
|
210
290
|
throw new Error('cdp launch only supports a localhost DOUBAO_CDP_ENDPOINT');
|
|
211
291
|
}
|
|
212
292
|
const port = endpoint.port || '9225';
|
|
293
|
+
const restarted = appRunning(appPath);
|
|
294
|
+
if (restarted) {
|
|
295
|
+
await confirmCdpRestart({ json, yes });
|
|
296
|
+
await quitAppForCdp(appPath);
|
|
297
|
+
}
|
|
213
298
|
const result = spawnSync('/usr/bin/open', ['-a', appPath, '--args', `--remote-debugging-port=${port}`], { encoding: 'utf8' });
|
|
214
299
|
if (result.status !== 0) throw new Error(result.stderr.trim() || 'failed to launch Doubao with CDP');
|
|
215
300
|
let launched = existing;
|
|
@@ -218,8 +303,10 @@ export async function main(argv) {
|
|
|
218
303
|
launched = await cdpStatus();
|
|
219
304
|
}
|
|
220
305
|
if (!launched.available) throw new Error(`Doubao launched, but CDP did not become available at ${existing.endpoint}`);
|
|
221
|
-
|
|
222
|
-
|
|
306
|
+
await waitForAutomationReady();
|
|
307
|
+
const launchResult = { ...launched, launched: true, restarted };
|
|
308
|
+
if (json) output(launchResult, true);
|
|
309
|
+
else console.log(`available\tyes\nendpoint\t${launched.endpoint}\nrestarted\t${restarted ? 'yes' : 'no'}`);
|
|
223
310
|
return;
|
|
224
311
|
}
|
|
225
312
|
|
|
@@ -269,6 +356,25 @@ export async function main(argv) {
|
|
|
269
356
|
return;
|
|
270
357
|
}
|
|
271
358
|
|
|
359
|
+
if (subcommand === 'create') {
|
|
360
|
+
const message = args.slice(2).join(' ');
|
|
361
|
+
const result = await createConversation(message, {
|
|
362
|
+
attachments,
|
|
363
|
+
model,
|
|
364
|
+
timeoutMs,
|
|
365
|
+
waitForReply: wait,
|
|
366
|
+
});
|
|
367
|
+
if (json) output(result, true);
|
|
368
|
+
else {
|
|
369
|
+
console.log(`created\t${result.conversationId || 'draft'}`);
|
|
370
|
+
if (result.model) console.log(`model\t${result.model}`);
|
|
371
|
+
for (const attachment of result.attachments || []) console.log(`attachment\t${attachment.name}`);
|
|
372
|
+
if (result.sent) console.log(`sent\t${result.sent.text}`);
|
|
373
|
+
if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
272
378
|
if (subcommand === 'open') {
|
|
273
379
|
const id = validateId(operand);
|
|
274
380
|
const url = openConversation(id);
|
|
@@ -288,11 +394,12 @@ export async function main(argv) {
|
|
|
288
394
|
if (subcommand === 'send') {
|
|
289
395
|
const id = validateId(operand);
|
|
290
396
|
const message = args.slice(3).join(' ');
|
|
291
|
-
const result = await sendMessage(id, message, { waitForReply: wait, timeoutMs, model });
|
|
397
|
+
const result = await sendMessage(id, message, { attachments, waitForReply: wait, timeoutMs, model });
|
|
292
398
|
if (json) output(result, true);
|
|
293
399
|
else {
|
|
294
400
|
console.log(`sent\t${result.sent.text}`);
|
|
295
401
|
if (result.model) console.log(`model\t${result.model}`);
|
|
402
|
+
for (const attachment of result.attachments || []) console.log(`attachment\t${attachment.name}`);
|
|
296
403
|
if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
|
|
297
404
|
}
|
|
298
405
|
return;
|