@lightcone-ai/daemon 0.14.1 → 0.14.3
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.
|
@@ -115,6 +115,9 @@ async function validateApproval(actionId, platform) {
|
|
|
115
115
|
throw new Error('approval_action_id is required. Request human approval first, then pass the approved action_id to publish_content.');
|
|
116
116
|
}
|
|
117
117
|
const data = await api('POST', `/actions/${encodeURIComponent(actionId)}/execute`, {});
|
|
118
|
+
if (data?.execution?.mode === 'user_daemon_job') {
|
|
119
|
+
throw new Error(`APPROVAL_DISPATCHED_TO_DAEMON_JOB:${data?.execution?.publish_job_id ?? ''}`);
|
|
120
|
+
}
|
|
118
121
|
if (data.platform && data.platform !== platform) {
|
|
119
122
|
throw new Error(`Approval platform mismatch: approved for "${data.platform}", requested "${platform}"`);
|
|
120
123
|
}
|
|
@@ -387,6 +390,12 @@ images/video 字段填写本地绝对路径(在 agent workspace 目录下)
|
|
|
387
390
|
content: [{ type: 'text', text: `✓ 已成功发布到${label}。${postUrl}${advisoryMessage}${healthMessage}` }],
|
|
388
391
|
};
|
|
389
392
|
} catch (err) {
|
|
393
|
+
if (err.message?.startsWith('APPROVAL_DISPATCHED_TO_DAEMON_JOB:')) {
|
|
394
|
+
return {
|
|
395
|
+
content: [{ type: 'text', text: '该审批动作已派发到用户侧 daemon job,请等待任务结果,不要直接调用 publish_content。' }],
|
|
396
|
+
isError: true,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
390
399
|
if (err.message?.startsWith('LOGIN_EXPIRED')) {
|
|
391
400
|
closeSession(platform);
|
|
392
401
|
await completeApproval(approval_action_id, false, null, err.message);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import { resolveMcpServerEntrypoint } from '
|
|
2
|
+
import { resolveMcpServerEntrypoint } from '../../src/_vendor/mcp/registry.js';
|
|
3
3
|
|
|
4
4
|
function extractErrorText(result) {
|
|
5
5
|
const chunks = Array.isArray(result?.content) ? result.content : [];
|
package/package.json
CHANGED
package/src/agent-manager.js
CHANGED
|
@@ -16,6 +16,7 @@ import { injectWorkspaceContext } from './drivers/claude.js';
|
|
|
16
16
|
import { parseKimiLine, encodeKimiStdin } from './drivers/kimi.js';
|
|
17
17
|
import { startSession, stopSession, stopAllSessions } from './browser-login.js';
|
|
18
18
|
import { markInvalidatedLeases } from './governance-state.js';
|
|
19
|
+
import { runPublishJob } from './publish-job-runner.js';
|
|
19
20
|
|
|
20
21
|
const KIMI_SYSTEM_PROMPT_FILE = '.lightcone-kimi-system.md';
|
|
21
22
|
const KIMI_AGENT_FILE = '.lightcone-kimi-agent.yaml';
|
|
@@ -156,6 +157,7 @@ export class AgentManager {
|
|
|
156
157
|
case 'agent:start': return this._startAgent(msg, connection);
|
|
157
158
|
case 'agent:stop': return this._stopAgent(msg.agentId, msg.workspaceId, connection);
|
|
158
159
|
case 'agent:deliver': return this._deliverMessage(msg, connection);
|
|
160
|
+
case 'publish:job': return this._handlePublishJob(msg, connection);
|
|
159
161
|
case 'browser:start_login': return this._startBrowserLogin(msg, connection);
|
|
160
162
|
case 'browser:stop_login': return this._stopBrowserLogin(msg);
|
|
161
163
|
case 'policy_invalidate': return this._handlePolicyInvalidate(msg, connection);
|
|
@@ -1005,6 +1007,96 @@ export class AgentManager {
|
|
|
1005
1007
|
}
|
|
1006
1008
|
}
|
|
1007
1009
|
|
|
1010
|
+
async _postInternalActionComplete({ agentId, actionId, ok, result, error }) {
|
|
1011
|
+
const url = `${this.serverUrl.replace(/\/$/, '')}/internal/agent/${encodeURIComponent(agentId)}/actions/${encodeURIComponent(actionId)}/complete`;
|
|
1012
|
+
const res = await fetch(url, {
|
|
1013
|
+
method: 'POST',
|
|
1014
|
+
headers: {
|
|
1015
|
+
'Content-Type': 'application/json',
|
|
1016
|
+
Authorization: `Bearer ${this.machineApiKey}`,
|
|
1017
|
+
},
|
|
1018
|
+
body: JSON.stringify({ ok, result, error }),
|
|
1019
|
+
});
|
|
1020
|
+
if (!res.ok) {
|
|
1021
|
+
const text = await res.text();
|
|
1022
|
+
throw new Error(`action complete failed (${res.status}): ${text}`);
|
|
1023
|
+
}
|
|
1024
|
+
return res.json();
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async _handlePublishJob(msg, connection) {
|
|
1028
|
+
const job = normalizeObject(msg.job);
|
|
1029
|
+
const jobId = String(job.id ?? '').trim();
|
|
1030
|
+
const actionId = String(job.approval_action_id ?? '').trim();
|
|
1031
|
+
const agentId = String(job.agent_id ?? '').trim();
|
|
1032
|
+
const workspaceId = String(job.workspace_id ?? '').trim() || null;
|
|
1033
|
+
|
|
1034
|
+
if (!jobId || !actionId || !agentId) {
|
|
1035
|
+
console.warn('[AgentManager] publish:job missing required fields (id, approval_action_id, agent_id)');
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
connection.send({
|
|
1040
|
+
type: 'publish:job_status',
|
|
1041
|
+
job_id: jobId,
|
|
1042
|
+
action_id: actionId,
|
|
1043
|
+
status: 'running',
|
|
1044
|
+
started_at: new Date().toISOString(),
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
const workspaceDir = this._workspaceDir(agentId, workspaceId);
|
|
1049
|
+
const publishResult = await runPublishJob({
|
|
1050
|
+
serverUrl: this.serverUrl,
|
|
1051
|
+
machineApiKey: this.machineApiKey,
|
|
1052
|
+
agentId,
|
|
1053
|
+
workspaceDir,
|
|
1054
|
+
job,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
await this._postInternalActionComplete({
|
|
1058
|
+
agentId,
|
|
1059
|
+
actionId,
|
|
1060
|
+
ok: true,
|
|
1061
|
+
result: publishResult.completionResult,
|
|
1062
|
+
error: null,
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
connection.send({
|
|
1066
|
+
type: 'publish:job_status',
|
|
1067
|
+
job_id: jobId,
|
|
1068
|
+
action_id: actionId,
|
|
1069
|
+
status: 'succeeded',
|
|
1070
|
+
completed_at: new Date().toISOString(),
|
|
1071
|
+
result: publishResult.completionResult,
|
|
1072
|
+
});
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
const errorMessage = err?.message ?? String(err);
|
|
1075
|
+
try {
|
|
1076
|
+
await this._postInternalActionComplete({
|
|
1077
|
+
agentId,
|
|
1078
|
+
actionId,
|
|
1079
|
+
ok: false,
|
|
1080
|
+
result: null,
|
|
1081
|
+
error: errorMessage,
|
|
1082
|
+
});
|
|
1083
|
+
} catch (completeErr) {
|
|
1084
|
+
console.error(
|
|
1085
|
+
`[AgentManager] Failed to report publish job completion for action=${actionId}: ${completeErr.message}`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
connection.send({
|
|
1090
|
+
type: 'publish:job_status',
|
|
1091
|
+
job_id: jobId,
|
|
1092
|
+
action_id: actionId,
|
|
1093
|
+
status: 'failed',
|
|
1094
|
+
completed_at: new Date().toISOString(),
|
|
1095
|
+
error: errorMessage,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1008
1100
|
_stopAgent(agentId, workspaceId, connection) {
|
|
1009
1101
|
const key = this._key(agentId, workspaceId);
|
|
1010
1102
|
const agent = this.agents.get(key);
|
package/src/chat-bridge.js
CHANGED
|
@@ -1580,6 +1580,15 @@ server.tool('execute_approved_action',
|
|
|
1580
1580
|
try {
|
|
1581
1581
|
const data = await api('POST', `/actions/${action_id}/execute`, {});
|
|
1582
1582
|
if (data.error) return { isError: true, content: [{ type: 'text', text: `Failed: ${data.error}` }] };
|
|
1583
|
+
if (data?.execution?.mode === 'user_daemon_job') {
|
|
1584
|
+
return { content: [{ type: 'text', text:
|
|
1585
|
+
`Action approved. Publish has been routed to a user-side daemon job.\n` +
|
|
1586
|
+
`actionType=${data.actionType} platform=${data.platform}\n` +
|
|
1587
|
+
`publish_job_id=${data.execution.publish_job_id} target_machine_id=${data.execution.target_machine_id}\n` +
|
|
1588
|
+
`status=${data.execution.status}\n` +
|
|
1589
|
+
`Do not call publish_content for this action_id again; wait for the daemon job result.`
|
|
1590
|
+
}]};
|
|
1591
|
+
}
|
|
1583
1592
|
return { content: [{ type: 'text', text:
|
|
1584
1593
|
`Action approved. Now call the appropriate platform tool with approval_action_id="${action_id}" to actually perform the operation.\n` +
|
|
1585
1594
|
`actionType=${data.actionType} platform=${data.platform}\n` +
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { existsSync, statSync, realpathSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getSession, closeSession } from '../mcp-servers/publisher/chrome-pool.js';
|
|
4
|
+
import { XhsAdapter } from '../mcp-servers/publisher/adapters/xhs.js';
|
|
5
|
+
import { DouyinAdapter } from '../mcp-servers/publisher/adapters/douyin.js';
|
|
6
|
+
import { KuaishouAdapter } from '../mcp-servers/publisher/adapters/kuaishou.js';
|
|
7
|
+
import { callOfficialTool } from '../mcp-servers/publisher/official-tool-client.js';
|
|
8
|
+
import { runPublishPrecheck } from '../mcp-servers/publisher/precheck.js';
|
|
9
|
+
import { withProfileLock } from './profile-lock.js';
|
|
10
|
+
|
|
11
|
+
const PLATFORM_ENV_KEYS = {
|
|
12
|
+
xhs: 'XHS_PROFILE_DIR',
|
|
13
|
+
douyin: 'DOUYIN_PROFILE_DIR',
|
|
14
|
+
kuaishou: 'KUAISHOU_PROFILE_DIR',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ADAPTER_REGISTRY = Object.freeze({
|
|
18
|
+
xhs: XhsAdapter,
|
|
19
|
+
douyin: DouyinAdapter,
|
|
20
|
+
kuaishou: KuaishouAdapter,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MEDIA_LIMITS = {
|
|
24
|
+
xhs: { maxImages: 9, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov'] },
|
|
25
|
+
douyin: { maxImages: 35, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov', '.webm'] },
|
|
26
|
+
kuaishou: { maxImages: 12, imageExts: ['.jpg', '.jpeg', '.png'], videoExts: ['.mp4', '.mov'] },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function normalizeText(value) {
|
|
30
|
+
const normalized = String(value ?? '').trim();
|
|
31
|
+
return normalized || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asObject(value) {
|
|
35
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getProfileDir(platform) {
|
|
40
|
+
const key = PLATFORM_ENV_KEYS[platform];
|
|
41
|
+
if (!key) return null;
|
|
42
|
+
return process.env[key] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getAdapterClass(platform) {
|
|
46
|
+
const AdapterClass = ADAPTER_REGISTRY[platform];
|
|
47
|
+
if (!AdapterClass) throw new Error(`Unknown platform: ${platform}`);
|
|
48
|
+
return AdapterClass;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createStaticAdapter(platform) {
|
|
52
|
+
const AdapterClass = getAdapterClass(platform);
|
|
53
|
+
return new AdapterClass(null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function getAdapter(platform) {
|
|
57
|
+
const profileDir = getProfileDir(platform);
|
|
58
|
+
if (!profileDir) {
|
|
59
|
+
throw new Error(`No profile dir for platform="${platform}". Has the user logged in and authorized this machine?`);
|
|
60
|
+
}
|
|
61
|
+
const cdp = await getSession(platform, profileDir);
|
|
62
|
+
const AdapterClass = getAdapterClass(platform);
|
|
63
|
+
return new AdapterClass(cdp);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function withPublisherProfile(platform, fn) {
|
|
67
|
+
const profileDir = getProfileDir(platform);
|
|
68
|
+
if (!profileDir) {
|
|
69
|
+
throw new Error(`No profile dir for platform="${platform}". Has the user logged in and authorized this machine?`);
|
|
70
|
+
}
|
|
71
|
+
return withProfileLock(platform, profileDir, {
|
|
72
|
+
owner: `publisher:${platform}`,
|
|
73
|
+
timeoutMs: 30_000,
|
|
74
|
+
staleMs: 20 * 60 * 1000,
|
|
75
|
+
}, fn);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isInsideDir(filePath, dir) {
|
|
79
|
+
const rel = path.relative(dir, filePath);
|
|
80
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeWorkspaceContent(content) {
|
|
84
|
+
if (typeof content !== 'string') throw new Error('workspace file content is not a string');
|
|
85
|
+
if (!content.startsWith('data:')) return Buffer.from(content, 'utf8');
|
|
86
|
+
|
|
87
|
+
const commaIdx = content.indexOf(',');
|
|
88
|
+
if (commaIdx === -1) throw new Error('invalid data URL in workspace file');
|
|
89
|
+
const header = content.slice(5, commaIdx);
|
|
90
|
+
const body = content.slice(commaIdx + 1);
|
|
91
|
+
if (header.split(';').includes('base64')) return Buffer.from(body, 'base64');
|
|
92
|
+
return Buffer.from(decodeURIComponent(body), 'utf8');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function internalApi({ serverUrl, machineApiKey, agentId, method, endpoint, body }) {
|
|
96
|
+
const url = `${String(serverUrl).replace(/\/$/, '')}/internal/agent/${encodeURIComponent(agentId)}${endpoint}`;
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Authorization: `Bearer ${machineApiKey}`,
|
|
102
|
+
},
|
|
103
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
104
|
+
});
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
throw new Error(`internal API ${method} ${endpoint} failed (${res.status}): ${text}`);
|
|
108
|
+
}
|
|
109
|
+
return res.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function workspacePathFromMediaPath(filePath, workspaceId) {
|
|
113
|
+
if (!filePath) return null;
|
|
114
|
+
|
|
115
|
+
const normalized = filePath.replaceAll('\\', '/');
|
|
116
|
+
const virtualMatch = normalized.match(/^\/agent-workspace\/([^/]+)\/workspace\/(.+)$/);
|
|
117
|
+
if (virtualMatch) return { workspaceId: virtualMatch[1], relPath: virtualMatch[2] };
|
|
118
|
+
|
|
119
|
+
const workspaceSegmentMatch = normalized.match(/\/workspace\/((?:artifacts|notes|tmp)\/.+)$/);
|
|
120
|
+
if (workspaceSegmentMatch) {
|
|
121
|
+
return { workspaceId, relPath: workspaceSegmentMatch[1] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!path.isAbsolute(filePath) && /^(artifacts|notes|tmp)\//.test(normalized)) {
|
|
125
|
+
return { workspaceId, relPath: normalized };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function materializeWorkspaceMedia({ filePath, workspaceId, workspaceRootDir, serverUrl, machineApiKey, agentId }) {
|
|
132
|
+
if (!filePath || existsSync(filePath)) return filePath;
|
|
133
|
+
|
|
134
|
+
const workspacePath = workspacePathFromMediaPath(filePath, workspaceId);
|
|
135
|
+
if (!workspacePath?.workspaceId || !workspacePath.relPath) return filePath;
|
|
136
|
+
|
|
137
|
+
const localPath = path.resolve(workspaceRootDir, workspacePath.relPath);
|
|
138
|
+
const allowedRoots = ['artifacts', 'notes', 'tmp'].map(dir => path.join(workspaceRootDir, dir));
|
|
139
|
+
if (!allowedRoots.some(root => isInsideDir(localPath, root))) {
|
|
140
|
+
throw new Error(`workspace media path is outside allowed workspace directories: ${filePath}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (existsSync(localPath)) return localPath;
|
|
144
|
+
|
|
145
|
+
const data = await internalApi({
|
|
146
|
+
serverUrl,
|
|
147
|
+
machineApiKey,
|
|
148
|
+
agentId,
|
|
149
|
+
method: 'GET',
|
|
150
|
+
endpoint: `/workspace-memory?path=${encodeURIComponent(workspacePath.relPath)}&workspaceId=${encodeURIComponent(workspacePath.workspaceId)}`,
|
|
151
|
+
});
|
|
152
|
+
mkdirSync(path.dirname(localPath), { recursive: true });
|
|
153
|
+
writeFileSync(localPath, decodeWorkspaceContent(data.content));
|
|
154
|
+
return localPath;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function materializeMedia({
|
|
158
|
+
images = [],
|
|
159
|
+
video,
|
|
160
|
+
cover,
|
|
161
|
+
workspaceId,
|
|
162
|
+
workspaceRootDir,
|
|
163
|
+
serverUrl,
|
|
164
|
+
machineApiKey,
|
|
165
|
+
agentId,
|
|
166
|
+
}) {
|
|
167
|
+
return {
|
|
168
|
+
images: await Promise.all(images.map(filePath => materializeWorkspaceMedia({
|
|
169
|
+
filePath,
|
|
170
|
+
workspaceId,
|
|
171
|
+
workspaceRootDir,
|
|
172
|
+
serverUrl,
|
|
173
|
+
machineApiKey,
|
|
174
|
+
agentId,
|
|
175
|
+
}))),
|
|
176
|
+
video: video ? await materializeWorkspaceMedia({
|
|
177
|
+
filePath: video,
|
|
178
|
+
workspaceId,
|
|
179
|
+
workspaceRootDir,
|
|
180
|
+
serverUrl,
|
|
181
|
+
machineApiKey,
|
|
182
|
+
agentId,
|
|
183
|
+
}) : video,
|
|
184
|
+
cover: cover ? await materializeWorkspaceMedia({
|
|
185
|
+
filePath: cover,
|
|
186
|
+
workspaceId,
|
|
187
|
+
workspaceRootDir,
|
|
188
|
+
serverUrl,
|
|
189
|
+
machineApiKey,
|
|
190
|
+
agentId,
|
|
191
|
+
}) : cover,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validateLocalFile(filePath, { kind, required = false, allowedExts = [], workspaceDir, workspaceRootDir }) {
|
|
196
|
+
if (!filePath) {
|
|
197
|
+
if (required) throw new Error(`${kind} file path is required`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (!path.isAbsolute(filePath)) {
|
|
201
|
+
throw new Error(`${kind} path must be absolute: ${filePath}`);
|
|
202
|
+
}
|
|
203
|
+
if (!existsSync(filePath)) {
|
|
204
|
+
throw new Error(`${kind} file does not exist: ${filePath}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const realFile = realpathSync(filePath);
|
|
208
|
+
const allowedRoots = [
|
|
209
|
+
realpathSync(workspaceDir),
|
|
210
|
+
...['artifacts', 'notes', 'tmp']
|
|
211
|
+
.map(dir => path.join(workspaceRootDir, dir))
|
|
212
|
+
.filter(existsSync)
|
|
213
|
+
.map(dir => realpathSync(dir)),
|
|
214
|
+
];
|
|
215
|
+
if (!allowedRoots.some(root => isInsideDir(realFile, root))) {
|
|
216
|
+
throw new Error(`${kind} file must be inside the daemon workspace or shared artifacts/notes/tmp directories: ${filePath}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const stat = statSync(realFile);
|
|
220
|
+
if (!stat.isFile()) throw new Error(`${kind} path is not a file: ${filePath}`);
|
|
221
|
+
const ext = path.extname(realFile).toLowerCase();
|
|
222
|
+
if (allowedExts.length > 0 && !allowedExts.includes(ext)) {
|
|
223
|
+
throw new Error(`${kind} file has unsupported extension "${ext}". Allowed: ${allowedExts.join(', ')}`);
|
|
224
|
+
}
|
|
225
|
+
return realFile;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeFormatExts(formats = [], fallback = []) {
|
|
229
|
+
if (!Array.isArray(formats) || formats.length === 0) return fallback;
|
|
230
|
+
return formats
|
|
231
|
+
.map(item => String(item ?? '').trim().toLowerCase())
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.map(item => (item.startsWith('.') ? item : `.${item}`));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resolveMediaLimits(platform, contentType) {
|
|
237
|
+
const fallback = DEFAULT_MEDIA_LIMITS[platform] ?? DEFAULT_MEDIA_LIMITS.xhs;
|
|
238
|
+
const adapter = createStaticAdapter(platform);
|
|
239
|
+
const requirements = adapter.getRequirements(contentType) ?? {};
|
|
240
|
+
const capabilities = typeof adapter.getCapabilities === 'function'
|
|
241
|
+
? (adapter.getCapabilities() ?? {})
|
|
242
|
+
: {};
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
maxImages: Number(requirements.max_images ?? capabilities.max_image ?? fallback.maxImages),
|
|
246
|
+
imageExts: normalizeFormatExts(requirements.image_formats ?? capabilities.image_formats, fallback.imageExts),
|
|
247
|
+
videoExts: normalizeFormatExts(requirements.video_formats ?? capabilities.video_formats, fallback.videoExts),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateMedia({ platform, contentType, images = [], video, cover, workspaceDir, workspaceRootDir }) {
|
|
252
|
+
const limits = resolveMediaLimits(platform, contentType);
|
|
253
|
+
if (contentType === 'image_text') {
|
|
254
|
+
if (images.length === 0) {
|
|
255
|
+
throw new Error(`image_text requires at least 1 image on ${platform}.`);
|
|
256
|
+
}
|
|
257
|
+
if (images.length > limits.maxImages) {
|
|
258
|
+
throw new Error(`image_text supports at most ${limits.maxImages} images on ${platform}`);
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
images: images.map(filePath => validateLocalFile(filePath, {
|
|
262
|
+
kind: 'image',
|
|
263
|
+
required: true,
|
|
264
|
+
allowedExts: limits.imageExts,
|
|
265
|
+
workspaceDir,
|
|
266
|
+
workspaceRootDir,
|
|
267
|
+
})),
|
|
268
|
+
video: null,
|
|
269
|
+
cover: null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
images: [],
|
|
275
|
+
video: validateLocalFile(video, {
|
|
276
|
+
kind: 'video',
|
|
277
|
+
required: true,
|
|
278
|
+
allowedExts: limits.videoExts,
|
|
279
|
+
workspaceDir,
|
|
280
|
+
workspaceRootDir,
|
|
281
|
+
}),
|
|
282
|
+
cover: cover ? validateLocalFile(cover, {
|
|
283
|
+
kind: 'cover',
|
|
284
|
+
allowedExts: limits.imageExts,
|
|
285
|
+
workspaceDir,
|
|
286
|
+
workspaceRootDir,
|
|
287
|
+
}) : null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizeContentType(value) {
|
|
292
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
293
|
+
if (normalized === 'image_text' || normalized === 'short_video') return normalized;
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function ensureStringArray(value) {
|
|
298
|
+
if (!Array.isArray(value)) return [];
|
|
299
|
+
return value.map(item => String(item ?? '')).filter(Boolean);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildJobInput(job = {}) {
|
|
303
|
+
const payload = asObject(job.payload);
|
|
304
|
+
const platform = String(job.platform ?? payload.platform ?? '').trim().toLowerCase();
|
|
305
|
+
const contentType = normalizeContentType(
|
|
306
|
+
payload.content_type ?? payload.contentType ?? job.content_type ?? job.contentType
|
|
307
|
+
);
|
|
308
|
+
const workspaceId = normalizeText(job.workspace_id ?? job.workspaceId ?? payload.workspace_id ?? payload.workspaceId);
|
|
309
|
+
const title = payload.title;
|
|
310
|
+
const text = payload.text;
|
|
311
|
+
const tags = ensureStringArray(payload.tags);
|
|
312
|
+
const images = ensureStringArray(payload.images);
|
|
313
|
+
const video = normalizeText(payload.video);
|
|
314
|
+
const cover = normalizeText(payload.cover);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
payload,
|
|
318
|
+
platform,
|
|
319
|
+
contentType,
|
|
320
|
+
workspaceId,
|
|
321
|
+
title,
|
|
322
|
+
text,
|
|
323
|
+
tags,
|
|
324
|
+
images,
|
|
325
|
+
video,
|
|
326
|
+
cover,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function runPublishJob({ serverUrl, machineApiKey, agentId, workspaceDir, job }) {
|
|
331
|
+
if (!serverUrl || !machineApiKey || !agentId) {
|
|
332
|
+
throw new Error('runPublishJob requires serverUrl, machineApiKey, and agentId');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const {
|
|
336
|
+
payload,
|
|
337
|
+
platform,
|
|
338
|
+
contentType,
|
|
339
|
+
workspaceId,
|
|
340
|
+
title,
|
|
341
|
+
text,
|
|
342
|
+
tags,
|
|
343
|
+
images,
|
|
344
|
+
video,
|
|
345
|
+
cover,
|
|
346
|
+
} = buildJobInput(job);
|
|
347
|
+
|
|
348
|
+
if (!platform) throw new Error('publish job missing platform');
|
|
349
|
+
if (!contentType) throw new Error('publish job missing content_type');
|
|
350
|
+
if (typeof text !== 'string' || !text.trim()) throw new Error('publish job missing text');
|
|
351
|
+
|
|
352
|
+
const resolvedWorkspaceDir = workspaceDir ?? process.cwd();
|
|
353
|
+
const workspaceRootDir = path.dirname(resolvedWorkspaceDir);
|
|
354
|
+
|
|
355
|
+
const precheck = await runPublishPrecheck({
|
|
356
|
+
platform,
|
|
357
|
+
title,
|
|
358
|
+
text,
|
|
359
|
+
tags,
|
|
360
|
+
payload,
|
|
361
|
+
callTool: callOfficialTool,
|
|
362
|
+
});
|
|
363
|
+
if (!precheck.ok) {
|
|
364
|
+
const blockerSummary = precheck.blockers.map(item => `${item.code}: ${item.message}`).join(' | ');
|
|
365
|
+
throw new Error(`PUBLISH_PRECHECK_BLOCKED:${blockerSummary || 'precheck failed'}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const localMedia = await materializeMedia({
|
|
369
|
+
images,
|
|
370
|
+
video,
|
|
371
|
+
cover,
|
|
372
|
+
workspaceId,
|
|
373
|
+
workspaceRootDir,
|
|
374
|
+
serverUrl,
|
|
375
|
+
machineApiKey,
|
|
376
|
+
agentId,
|
|
377
|
+
});
|
|
378
|
+
const staticAdapter = createStaticAdapter(platform);
|
|
379
|
+
const req = staticAdapter.getRequirements(contentType);
|
|
380
|
+
if (!req) throw new Error(`INPUT_UNSUPPORTED_CONTENT_TYPE: ${platform} does not support ${contentType}`);
|
|
381
|
+
|
|
382
|
+
const media = validateMedia({
|
|
383
|
+
platform,
|
|
384
|
+
contentType,
|
|
385
|
+
...localMedia,
|
|
386
|
+
workspaceDir: resolvedWorkspaceDir,
|
|
387
|
+
workspaceRootDir,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const { publishResult, healthCheck } = await withPublisherProfile(platform, async () => {
|
|
392
|
+
const adapter = await getAdapter(platform);
|
|
393
|
+
const prePublishLogin = await adapter.checkLoginStatus();
|
|
394
|
+
if (prePublishLogin?.loggedIn === false) {
|
|
395
|
+
throw new Error(`LOGIN_EXPIRED:${platform}`);
|
|
396
|
+
}
|
|
397
|
+
let publishResult = null;
|
|
398
|
+
if (contentType === 'image_text') {
|
|
399
|
+
publishResult = await adapter.publishImageText({ title, text, tags, images: media.images });
|
|
400
|
+
} else {
|
|
401
|
+
publishResult = await adapter.publishShortVideo({ title, text, tags, video: media.video, cover: media.cover });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let healthCheck = null;
|
|
405
|
+
try {
|
|
406
|
+
healthCheck = await adapter.checkLoginStatus();
|
|
407
|
+
} catch (error) {
|
|
408
|
+
healthCheck = {
|
|
409
|
+
loggedIn: null,
|
|
410
|
+
error: error.message,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return { publishResult, healthCheck };
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
completionResult: {
|
|
418
|
+
precheck: {
|
|
419
|
+
blockers: precheck.blockers,
|
|
420
|
+
warnings: precheck.warnings,
|
|
421
|
+
policy_version: precheck?.policyScan?.policy_version ?? null,
|
|
422
|
+
advisory: precheck?.advisory?.advisory ?? null,
|
|
423
|
+
},
|
|
424
|
+
pre_publish_login: true,
|
|
425
|
+
publish_result: publishResult,
|
|
426
|
+
post_publish_health: healthCheck,
|
|
427
|
+
},
|
|
428
|
+
publishResult,
|
|
429
|
+
precheck,
|
|
430
|
+
healthCheck,
|
|
431
|
+
};
|
|
432
|
+
} catch (err) {
|
|
433
|
+
if (err.message?.startsWith('LOGIN_EXPIRED')) {
|
|
434
|
+
closeSession(platform);
|
|
435
|
+
}
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
}
|