@lightcone-ai/daemon 0.9.76 → 0.9.78
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/mcp-servers/publisher/adapters/douyin.js +118 -7
- package/package.json +1 -1
- package/src/chat-bridge.js +1 -40
- package/src/drivers/claude.js +8 -18
|
@@ -37,7 +37,14 @@ const CREATOR_HOME_URL = 'https://creator.douyin.com/creator-micro/home';
|
|
|
37
37
|
const IMAGE_PUBLISH_URL = 'https://creator.douyin.com/creator-micro/content/upload-image-text';
|
|
38
38
|
const VIDEO_PUBLISH_URL = 'https://creator.douyin.com/creator-micro/content/upload';
|
|
39
39
|
|
|
40
|
-
const FILE_INPUT_SELECTOR =
|
|
40
|
+
const FILE_INPUT_SELECTOR = [
|
|
41
|
+
'input[type="file"][accept*="image"]',
|
|
42
|
+
'input[type="file"][accept*=".jpg"]',
|
|
43
|
+
'input[type="file"][accept*=".jpeg"]',
|
|
44
|
+
'input[type="file"][accept*=".png"]',
|
|
45
|
+
'input[type="file"][accept*=".webp"]',
|
|
46
|
+
'input[type="file"]',
|
|
47
|
+
].join(', ');
|
|
41
48
|
const TITLE_SELECTOR = 'input[placeholder*="标题"], textarea[placeholder*="标题"]';
|
|
42
49
|
const CONTENT_SELECTOR = '[contenteditable="true"], textarea[placeholder*="描述"], textarea[placeholder*="作品描述"], [placeholder*="添加作品描述"]';
|
|
43
50
|
|
|
@@ -79,7 +86,7 @@ export class DouyinAdapter {
|
|
|
79
86
|
await humanPause(2500, 5500, 'composer-ready');
|
|
80
87
|
|
|
81
88
|
if (images.length > 0) {
|
|
82
|
-
await this.
|
|
89
|
+
await this._ensureUploaderReady('image');
|
|
83
90
|
await humanPause(900, 2200, 'before-upload');
|
|
84
91
|
await this._uploadFiles(images);
|
|
85
92
|
await this._waitForUploadSettled(images.length, 120_000);
|
|
@@ -114,7 +121,7 @@ export class DouyinAdapter {
|
|
|
114
121
|
await this._assertReadyForPublish();
|
|
115
122
|
await humanPause(2500, 5500, 'composer-ready');
|
|
116
123
|
|
|
117
|
-
await this.
|
|
124
|
+
await this._ensureUploaderReady('video');
|
|
118
125
|
await humanPause(900, 2200, 'before-upload');
|
|
119
126
|
await this._uploadFiles([video]);
|
|
120
127
|
await this._waitForUploadSettled(1, 180_000);
|
|
@@ -152,6 +159,7 @@ export class DouyinAdapter {
|
|
|
152
159
|
await this._cdp.send('Page.navigate', { url });
|
|
153
160
|
await this._waitForCreatorShell(20_000);
|
|
154
161
|
await humanPause(1800, 4200, 'after-navigation');
|
|
162
|
+
await this._ensureComposer(kind);
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
async _waitForCreatorShell(timeoutMs = 20_000) {
|
|
@@ -187,6 +195,66 @@ export class DouyinAdapter {
|
|
|
187
195
|
throw new Error(`Timeout waiting for selector: ${selector}`);
|
|
188
196
|
}
|
|
189
197
|
|
|
198
|
+
async _ensureComposer(kind) {
|
|
199
|
+
if (await this._waitForUploaderOrEditor(8000)) return;
|
|
200
|
+
|
|
201
|
+
const labels = kind === 'video'
|
|
202
|
+
? ['发布视频', '上传视频', '视频', '发布作品', '上传']
|
|
203
|
+
: ['发布图文', '上传图文', '图文', '发布图片', '上传图片', '图片', '发布作品', '上传'];
|
|
204
|
+
|
|
205
|
+
for (const label of labels) {
|
|
206
|
+
const clicked = await this._clickByText(label);
|
|
207
|
+
if (!clicked) continue;
|
|
208
|
+
if (await this._waitForUploaderOrEditor(12_000)) return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await this._cdp.send('Page.navigate', { url: kind === 'video' ? VIDEO_PUBLISH_URL : IMAGE_PUBLISH_URL });
|
|
212
|
+
if (await this._waitForUploaderOrEditor(15_000)) return;
|
|
213
|
+
|
|
214
|
+
const state = await this._inspectPage();
|
|
215
|
+
throw new Error(`PUBLISH_FAILED: 找不到抖音${kind === 'image' ? '图文' : '视频'}上传入口。${this._formatPageHint(state)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async _ensureUploaderReady(kind) {
|
|
219
|
+
if (await this._waitForSelectorQuiet(FILE_INPUT_SELECTOR, 8000)) return;
|
|
220
|
+
await this._ensureComposer(kind);
|
|
221
|
+
if (await this._waitForSelectorQuiet(FILE_INPUT_SELECTOR, 12_000)) return;
|
|
222
|
+
|
|
223
|
+
const labels = kind === 'video'
|
|
224
|
+
? ['上传视频', '点击上传', '选择视频', '上传']
|
|
225
|
+
: ['上传图片', '添加图片', '点击上传', '选择图片', '上传'];
|
|
226
|
+
for (const label of labels) {
|
|
227
|
+
const clicked = await this._clickByText(label);
|
|
228
|
+
if (!clicked) continue;
|
|
229
|
+
if (await this._waitForSelectorQuiet(FILE_INPUT_SELECTOR, 8000)) return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const state = await this._inspectPage();
|
|
233
|
+
throw new Error(`PUBLISH_FAILED: 找不到抖音文件上传输入框。${this._formatPageHint(state)}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async _waitForUploaderOrEditor(timeoutMs) {
|
|
237
|
+
const deadline = Date.now() + timeoutMs;
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
const state = await this._inspectPage();
|
|
240
|
+
if (state.hasLoginHint) {
|
|
241
|
+
throw new Error(`LOGIN_EXPIRED: 当前页面 ${state.url},请重新扫码连接抖音`);
|
|
242
|
+
}
|
|
243
|
+
if (state.hasUploader || state.hasPublishEditor) return true;
|
|
244
|
+
await sleep(500);
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async _waitForSelectorQuiet(selector, timeoutMs = 8000) {
|
|
250
|
+
try {
|
|
251
|
+
await this._waitForSelector(selector, timeoutMs);
|
|
252
|
+
return true;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
190
258
|
async _inspectPage() {
|
|
191
259
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
192
260
|
expression: `
|
|
@@ -208,12 +276,28 @@ export class DouyinAdapter {
|
|
|
208
276
|
const buttons = [...document.querySelectorAll('button, [role="button"]')].map(e => ({
|
|
209
277
|
text: (e.innerText || e.textContent || '').trim(),
|
|
210
278
|
disabled: !!e.disabled || e.getAttribute('aria-disabled') === 'true' || e.className?.toString().includes('disabled'),
|
|
211
|
-
}));
|
|
279
|
+
})).filter(b => b.text).slice(0, 30);
|
|
280
|
+
const links = [...document.querySelectorAll('a')].map(e => ({
|
|
281
|
+
text: (e.innerText || e.textContent || '').trim(),
|
|
282
|
+
href: e.href || '',
|
|
283
|
+
})).filter(a => a.text).slice(0, 30);
|
|
284
|
+
const uploadish = [...document.querySelectorAll('input, button, [role="button"], a, div, span')]
|
|
285
|
+
.map(e => ({
|
|
286
|
+
tag: e.tagName,
|
|
287
|
+
text: (e.innerText || e.textContent || e.getAttribute('aria-label') || e.getAttribute('title') || '').trim(),
|
|
288
|
+
type: e.getAttribute('type') || '',
|
|
289
|
+
accept: e.getAttribute('accept') || '',
|
|
290
|
+
cls: e.className?.toString?.() || '',
|
|
291
|
+
}))
|
|
292
|
+
.filter(e => /上传|图文|图片|视频|作品|file|upload/i.test([e.text, e.type, e.accept, e.cls].join(' ')))
|
|
293
|
+
.slice(0, 40);
|
|
212
294
|
return {
|
|
213
295
|
url,
|
|
214
296
|
text: text.slice(0, 5000),
|
|
215
297
|
errors,
|
|
216
298
|
buttons,
|
|
299
|
+
links,
|
|
300
|
+
uploadish,
|
|
217
301
|
hasLoginHint: /登录|扫码|验证码|手机号/.test(text) && !/发布|作品管理|创作服务平台/.test(text),
|
|
218
302
|
isCreatorLoggedIn: /创作服务平台|创作者中心|发布作品|作品管理|内容管理/.test(text),
|
|
219
303
|
hasUploader: !!document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)}),
|
|
@@ -224,7 +308,19 @@ export class DouyinAdapter {
|
|
|
224
308
|
`,
|
|
225
309
|
returnByValue: true,
|
|
226
310
|
});
|
|
227
|
-
return result.result?.value ?? { url: await this._getUrl(), text: '', errors: [], buttons: [] };
|
|
311
|
+
return result.result?.value ?? { url: await this._getUrl(), text: '', errors: [], buttons: [], links: [], uploadish: [] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_formatPageHint(state) {
|
|
315
|
+
const buttons = (state.buttons ?? []).map(b => b.text).filter(Boolean).slice(0, 12).join(' / ');
|
|
316
|
+
const links = (state.links ?? []).map(a => a.text).filter(Boolean).slice(0, 12).join(' / ');
|
|
317
|
+
const uploadish = (state.uploadish ?? [])
|
|
318
|
+
.map(e => [e.tag, e.text, e.type, e.accept].filter(Boolean).join(':'))
|
|
319
|
+
.filter(Boolean)
|
|
320
|
+
.slice(0, 12)
|
|
321
|
+
.join(' / ');
|
|
322
|
+
const text = (state.text ?? '').replace(/\s+/g, ' ').slice(0, 240);
|
|
323
|
+
return `当前 url=${state.url}; buttons=${buttons || 'none'}; links=${links || 'none'}; uploadCandidates=${uploadish || 'none'}; text=${text || 'empty'}`;
|
|
228
324
|
}
|
|
229
325
|
|
|
230
326
|
async _assertReadyForPublish() {
|
|
@@ -368,8 +464,23 @@ export class DouyinAdapter {
|
|
|
368
464
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
369
465
|
expression: `
|
|
370
466
|
(function() {
|
|
371
|
-
const
|
|
372
|
-
|
|
467
|
+
const visible = (el) => {
|
|
468
|
+
const r = el.getBoundingClientRect();
|
|
469
|
+
const s = getComputedStyle(el);
|
|
470
|
+
return r.width > 0 && r.height > 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
|
|
471
|
+
};
|
|
472
|
+
const els = [...document.querySelectorAll('button, [role="button"], a, div, span')]
|
|
473
|
+
.filter(visible)
|
|
474
|
+
.filter(e => {
|
|
475
|
+
const txt = (e.innerText || e.textContent || e.getAttribute('aria-label') || e.getAttribute('title') || '').trim();
|
|
476
|
+
if (!txt) return false;
|
|
477
|
+
return txt === ${JSON.stringify(text)} || txt.includes(${JSON.stringify(text)});
|
|
478
|
+
});
|
|
479
|
+
const el = els.sort((a, b) => {
|
|
480
|
+
const ar = a.getBoundingClientRect();
|
|
481
|
+
const br = b.getBoundingClientRect();
|
|
482
|
+
return (ar.width * ar.height) - (br.width * br.height);
|
|
483
|
+
})[0];
|
|
373
484
|
if (!el) return null;
|
|
374
485
|
const r = el.getBoundingClientRect();
|
|
375
486
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
package/package.json
CHANGED
package/src/chat-bridge.js
CHANGED
|
@@ -113,46 +113,8 @@ server.tool('send_message', 'Send a message to a team, DM, or thread', {
|
|
|
113
113
|
return { content: [{ type: 'text', text: `Sent. messageId=${data.messageId} threadTarget=${data.threadTarget}` }] };
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
// ── read_history ──────────────────────────────────────────────────────────────
|
|
117
|
-
server.tool('read_history', 'Read message history from a team, DM, or thread. Supports pagination via before/after and context jumps via around.', {
|
|
118
|
-
team: z.string().describe('Target: #team-name | dm:@agentName | #team-name:shortMsgId'),
|
|
119
|
-
limit: z.number().optional().describe('Max messages to return (default 50, max 100)'),
|
|
120
|
-
around: z.union([z.string(), z.number()]).optional().describe('Center the result window around a messageId or seq number'),
|
|
121
|
-
before: z.number().optional().describe('Return messages before this seq'),
|
|
122
|
-
after: z.number().optional().describe('Return messages after this seq'),
|
|
123
|
-
}, async ({ team, limit, around, before, after }) => {
|
|
124
|
-
const params = new URLSearchParams({ team, limit: String(Math.min(limit ?? 50, 100)) });
|
|
125
|
-
if (around != null) params.set('around', String(around));
|
|
126
|
-
if (before != null) params.set('before', String(before));
|
|
127
|
-
if (after != null) params.set('after', String(after));
|
|
128
|
-
const data = await api('GET', `/history?${params}`);
|
|
129
|
-
const msgs = data.messages ?? [];
|
|
130
|
-
if (msgs.length === 0) return { content: [{ type: 'text', text: 'No messages found.' }] };
|
|
131
|
-
|
|
132
|
-
const text = msgs.map(m => {
|
|
133
|
-
const senderType = m.senderType === 'agent' ? ' type=agent' : '';
|
|
134
|
-
const taskSuffix = m.taskStatus ? ` [task #${m.taskNumber} status=${m.taskStatus}${m.taskAssigneeId ? ` assignee=${m.taskAssigneeType}:${m.taskAssigneeId}` : ''}]` : '';
|
|
135
|
-
return `[seq=${m.seq} msg=${m.id} time=${m.createdAt}${senderType}] @${m.senderName}: ${m.content}${taskSuffix}`;
|
|
136
|
-
}).join('\n');
|
|
137
|
-
|
|
138
|
-
let footer = '';
|
|
139
|
-
if (data.has_more && msgs.length > 0) {
|
|
140
|
-
const minSeq = msgs[0].seq;
|
|
141
|
-
const maxSeq = msgs[msgs.length - 1].seq;
|
|
142
|
-
if (around) {
|
|
143
|
-
footer = `\n\n--- Use before=${minSeq} to load older or after=${maxSeq} to load newer. ---`;
|
|
144
|
-
} else if (after) {
|
|
145
|
-
footer = `\n\n--- ${msgs.length} messages shown. Use after=${maxSeq} for more. ---`;
|
|
146
|
-
} else {
|
|
147
|
-
footer = `\n\n--- ${msgs.length} messages shown. Use before=${minSeq} for older. ---`;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return { content: [{ type: 'text', text: `## History for ${team} (${msgs.length} messages)\n\n${text}${footer}` }] };
|
|
152
|
-
});
|
|
153
|
-
|
|
154
116
|
// ── search_messages ──────────────────────────────────────────────────────────
|
|
155
|
-
server.tool('search_messages', 'Search messages within a specific team. You must specify the team. Use this to find relevant conversations
|
|
117
|
+
server.tool('search_messages', 'Search messages within a specific team. You must specify the team. Use this to find relevant conversations by keyword.', {
|
|
156
118
|
query: z.string().describe('Search query'),
|
|
157
119
|
team: z.string().describe('Target team to search within, e.g. "#general", "dm:@richard". Required — you may only search teams you are a member of.'),
|
|
158
120
|
limit: z.number().optional().describe('Max results (default 10, max 20)'),
|
|
@@ -171,7 +133,6 @@ server.tool('search_messages', 'Search messages within a specific team. You must
|
|
|
171
133
|
`team: #${r.teamName}`,
|
|
172
134
|
`sender: @${r.senderName}${r.senderType === 'agent' ? ' (agent)' : ''}`,
|
|
173
135
|
`content: ${r.snippet}`,
|
|
174
|
-
`next: read_history(team="#${r.teamName}", around="${r.id}", limit=20)`,
|
|
175
136
|
].join('\n')).join('\n\n');
|
|
176
137
|
return { content: [{ type: 'text', text: `## Search Results for "${trimmed}" (${data.results.length} results)\n\n${formatted}` }] };
|
|
177
138
|
} catch (err) {
|
package/src/drivers/claude.js
CHANGED
|
@@ -15,15 +15,14 @@ You have MCP tools from the "chat" server. Use ONLY these for communication:
|
|
|
15
15
|
1. **${t("check_messages")}** — Non-blocking check for new messages. Use freely during work — at natural breakpoints or after notifications.
|
|
16
16
|
2. **${t("send_message")}** — Send a message to a team or DM.
|
|
17
17
|
3. **${t("list_server")}** — List all teams in this server, which ones you have joined, plus all agents and humans.
|
|
18
|
-
4. **${t("
|
|
19
|
-
5. **${t("
|
|
20
|
-
6. **${t("
|
|
21
|
-
7. **${t("
|
|
22
|
-
8. **${t("
|
|
23
|
-
9. **${t("
|
|
24
|
-
10. **${t("
|
|
25
|
-
11. **${t("
|
|
26
|
-
12. **${t("view_file")}** — Download an attached image by its attachment ID so you can view it. Use when messages contain image attachments.
|
|
18
|
+
4. **${t("search_messages")}** — Search messages visible to you by keyword.
|
|
19
|
+
5. **${t("list_tasks")}** — View a team's task board.
|
|
20
|
+
6. **${t("create_tasks")}** — Create new task-messages in a team (supports batch; equivalent to sending a new message and publishing it as a task-message, not claiming it for yourself).
|
|
21
|
+
7. **${t("claim_tasks")}** — Claim tasks by number (supports batch, handles conflicts).
|
|
22
|
+
8. **${t("unclaim_task")}** — Release your claim on a task.
|
|
23
|
+
9. **${t("update_task_status")}** — Change a task's status (e.g. to in_review or done).
|
|
24
|
+
10. **${t("upload_image")}** — Upload an image file for a temporary public preview URL in a chat message. This is not durable artifact storage.
|
|
25
|
+
11. **${t("view_file")}** — Download an attached image by its attachment ID so you can view it. Use when messages contain image attachments.
|
|
27
26
|
|
|
28
27
|
CRITICAL RULES:
|
|
29
28
|
- Always communicate through ${t("send_message")}. This is your only output method.
|
|
@@ -73,7 +72,6 @@ Threads are sub-conversations attached to a specific message. They let you discu
|
|
|
73
72
|
- When you receive a message from a thread (the target has a \`:shortid\` suffix), **always reply using that same target** to keep the conversation in the thread.
|
|
74
73
|
- **Start a new thread**: Use the \`msg=\` field from the header as the thread suffix. For example, if you see \`[target=#general msg=a1b2c3d4 ...]\`, reply with \`send_message(target="#general:a1b2c3d4", content="...")\`. The thread will be auto-created if it doesn't exist yet.
|
|
75
74
|
- When you send a message, the response includes the message ID. You can use it to start a thread on your own message.
|
|
76
|
-
- You can read thread history: \`read_history(team="#general:a1b2c3d4")\`
|
|
77
75
|
- Threads cannot be nested — you cannot start a thread inside a thread.
|
|
78
76
|
|
|
79
77
|
### Discovering people and teams
|
|
@@ -87,12 +85,6 @@ Each team has a **name** and optionally a **description** that define its purpos
|
|
|
87
85
|
- **Stay on topic** — when proactively sharing results or updates, post in the team most relevant to the work. Don't scatter messages across unrelated teams.
|
|
88
86
|
- If unsure where something belongs, call \`list_server\` to review team descriptions.
|
|
89
87
|
|
|
90
|
-
### Reading history
|
|
91
|
-
|
|
92
|
-
\`read_history(team="#team-name")\` or \`read_history(team="dm:@peer-name")\` or \`read_history(team="#team:shortid")\`
|
|
93
|
-
|
|
94
|
-
To jump directly to a specific hit with nearby context, use \`read_history(team="...", around="messageId")\` or \`read_history(team="...", around=12345)\`.
|
|
95
|
-
|
|
96
88
|
### Tasks
|
|
97
89
|
|
|
98
90
|
When someone sends a message that asks you to do something — fix a bug, write code, review a PR, deploy, investigate an issue — that is work. Claim it before you start.
|
|
@@ -106,8 +98,6 @@ When someone sends a message that asks you to do something — fix a bug, write
|
|
|
106
98
|
|
|
107
99
|
Only top-level team / DM messages can become tasks. Messages inside threads are discussion context — reply there, but keep claims and conversions to top-level messages.
|
|
108
100
|
|
|
109
|
-
\`read_history\` shows messages in their current state. If a message was later converted to a task, it will show the \`[task #N ...]\` suffix.
|
|
110
|
-
|
|
111
101
|
**Status flow:** \`todo\` → \`in_progress\` → \`in_review\` → \`done\`
|
|
112
102
|
|
|
113
103
|
**Assignee** is independent from status — a task can be claimed or unclaimed at any status except \`done\`.
|