lobster-roundtable 3.0.5 → 3.0.7
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/main.js +146 -116
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.js +30 -13
- package/src/fileio.js +61 -0
package/main.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
getOpenClawApiPort,
|
|
23
23
|
getOpenClawApiToken,
|
|
24
24
|
} = require("./src/env.js");
|
|
25
|
+
const { readTextSafe, parseJsonFileFlexible } = require("./src/fileio.js");
|
|
25
26
|
// Node.js 原生 HTTP 请求工具(避免依赖外部命令)
|
|
26
27
|
function httpRequest(urlStr, options = {}) {
|
|
27
28
|
return new Promise((resolve, reject) => {
|
|
@@ -60,7 +61,7 @@ try {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const CHANNEL_ID = "lobster-roundtable";
|
|
63
|
-
const PLUGIN_VERSION = "3.0.
|
|
64
|
+
const PLUGIN_VERSION = "3.0.7";
|
|
64
65
|
const ENABLE_OPENCLAW_CONFIG_SYNC = isOpenClawConfigSyncEnabled();
|
|
65
66
|
const OPENCLAW_CONFIG_ALLOWED_KEYS = new Set(["url", "token", "ownerToken", "name", "persona", "maxTokens"]);
|
|
66
67
|
|
|
@@ -111,64 +112,6 @@ function ensureDirForFile(filePath) {
|
|
|
111
112
|
fsModule.mkdirSync(pathModule.dirname(filePath), { recursive: true });
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
function readTextSafe(filePath) {
|
|
115
|
-
try {
|
|
116
|
-
return fsModule.existsSync(filePath) ? String(fsModule.readFileSync(filePath, "utf-8") || "").trim() : "";
|
|
117
|
-
} catch {
|
|
118
|
-
return "";
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function decodeFileBuffer(buf) {
|
|
123
|
-
if (!Buffer.isBuffer(buf) || buf.length === 0) return "";
|
|
124
|
-
// UTF-8 BOM
|
|
125
|
-
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
126
|
-
return buf.slice(3).toString("utf8");
|
|
127
|
-
}
|
|
128
|
-
// UTF-16 LE BOM
|
|
129
|
-
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
130
|
-
return buf.slice(2).toString("utf16le");
|
|
131
|
-
}
|
|
132
|
-
// UTF-16 BE BOM
|
|
133
|
-
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
134
|
-
const swapped = Buffer.allocUnsafe(buf.length - 2);
|
|
135
|
-
for (let i = 2; i + 1 < buf.length; i += 2) {
|
|
136
|
-
swapped[i - 2] = buf[i + 1];
|
|
137
|
-
swapped[i - 1] = buf[i];
|
|
138
|
-
}
|
|
139
|
-
return swapped.toString("utf16le");
|
|
140
|
-
}
|
|
141
|
-
// Heuristic: UTF-16 LE without BOM (lots of NUL bytes in odd positions)
|
|
142
|
-
const sample = buf.slice(0, Math.min(buf.length, 128));
|
|
143
|
-
let nulOdd = 0;
|
|
144
|
-
for (let i = 1; i < sample.length; i += 2) {
|
|
145
|
-
if (sample[i] === 0) nulOdd++;
|
|
146
|
-
}
|
|
147
|
-
if (sample.length > 8 && nulOdd >= sample.length / 6) {
|
|
148
|
-
return buf.toString("utf16le");
|
|
149
|
-
}
|
|
150
|
-
return buf.toString("utf8");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function parseJsonFileFlexible(filePath) {
|
|
154
|
-
const raw = fsModule.readFileSync(filePath);
|
|
155
|
-
let text = decodeFileBuffer(raw);
|
|
156
|
-
if (!text) return {};
|
|
157
|
-
text = String(text).replace(/^\uFEFF/, "").trim();
|
|
158
|
-
try {
|
|
159
|
-
return JSON.parse(text);
|
|
160
|
-
} catch {
|
|
161
|
-
// Recover from contaminated files like: log-prefix + {...json...} + log-suffix
|
|
162
|
-
const first = text.indexOf("{");
|
|
163
|
-
const last = text.lastIndexOf("}");
|
|
164
|
-
if (first >= 0 && last > first) {
|
|
165
|
-
const candidate = text.slice(first, last + 1).trim();
|
|
166
|
-
return JSON.parse(candidate);
|
|
167
|
-
}
|
|
168
|
-
throw new Error("invalid_json");
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
115
|
function writeJsonUtf8NoBomAtomic(filePath, value) {
|
|
173
116
|
const tmp = `${filePath}.tmp`;
|
|
174
117
|
const out = JSON.stringify(value, null, 2);
|
|
@@ -611,10 +554,17 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
611
554
|
} catch { }
|
|
612
555
|
}
|
|
613
556
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
557
|
+
function safeDiagText(v, max = 280) {
|
|
558
|
+
const s = String(v || "").trim();
|
|
559
|
+
return s.length > max ? s.slice(0, max) : s;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function maskTokenForDiag(raw) {
|
|
563
|
+
const s = String(raw || "").trim();
|
|
564
|
+
if (!s) return "";
|
|
565
|
+
if (s.length <= 8) return s;
|
|
566
|
+
return `${s.slice(0, 6)}...${s.slice(-4)}`;
|
|
567
|
+
}
|
|
618
568
|
|
|
619
569
|
function reportDiag(event, payload = {}, throttleMs = 8000) {
|
|
620
570
|
if (!event) return;
|
|
@@ -639,7 +589,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
639
589
|
roomMode: currentRoomMode || "",
|
|
640
590
|
wsUrl,
|
|
641
591
|
botName: myName || "",
|
|
642
|
-
|
|
592
|
+
tokenMasked: maskTokenForDiag(token),
|
|
643
593
|
instanceId: instanceId || "",
|
|
644
594
|
sessionId: sessionId || "",
|
|
645
595
|
detail: payload.detail && typeof payload.detail === "object" ? payload.detail : null,
|
|
@@ -661,7 +611,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
661
611
|
roomMode: currentRoomMode || "",
|
|
662
612
|
wsUrl,
|
|
663
613
|
botName: myName || "",
|
|
664
|
-
|
|
614
|
+
tokenMasked: maskTokenForDiag(token),
|
|
665
615
|
instanceId: instanceId || "",
|
|
666
616
|
sessionId: sessionId || "",
|
|
667
617
|
stack: safeDiagText(payload.stack || "", 1200),
|
|
@@ -1098,6 +1048,67 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1098
1048
|
}
|
|
1099
1049
|
}
|
|
1100
1050
|
|
|
1051
|
+
function getPromptTimeoutMs(turnContext) {
|
|
1052
|
+
const ctx = String(turnContext || '').trim().toLowerCase();
|
|
1053
|
+
switch (ctx) {
|
|
1054
|
+
case 'evo_review':
|
|
1055
|
+
return 22000; // server discuss timeout: 25s
|
|
1056
|
+
case 'evo_share':
|
|
1057
|
+
case 'evo_experience':
|
|
1058
|
+
return 38000; // server share timeout: 45s
|
|
1059
|
+
case 'evo_vote':
|
|
1060
|
+
return 18000; // server vote timeout: 20~30s
|
|
1061
|
+
case 'debate_speak':
|
|
1062
|
+
return 24000; // server debate speak timeout: 30s
|
|
1063
|
+
case 'debate_judge':
|
|
1064
|
+
return 42000; // server judge timeout: 45s
|
|
1065
|
+
case 'host_comment':
|
|
1066
|
+
return 12000; // server host comment timeout: 15s
|
|
1067
|
+
case 'host_summary':
|
|
1068
|
+
return 42000; // server host summary timeout: 45s
|
|
1069
|
+
case 'speaking':
|
|
1070
|
+
return 38000; // server generic speaking timeout: 45s
|
|
1071
|
+
default:
|
|
1072
|
+
return 28000; // conservative default to avoid late replies
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function buildPromptFallbackReply(turnContext, promptText = '') {
|
|
1077
|
+
const ctx = String(turnContext || '').trim().toLowerCase();
|
|
1078
|
+
if (ctx === 'evo_vote') return '不通过';
|
|
1079
|
+
if (ctx === 'host_comment') return '这个点挺有意思,我先记下,继续下一位。';
|
|
1080
|
+
if (ctx === 'host_summary') return '本轮先总结到这里:观点已收集,下一轮继续聚焦核心分歧。';
|
|
1081
|
+
if (ctx === 'evo_review') return '我暂时倾向不通过,建议补充可复用步骤与真实案例。';
|
|
1082
|
+
if (ctx === 'evo_share' || ctx === 'evo_experience') {
|
|
1083
|
+
return '我补充一点可落地经验:先明确目标,再小步验证,再按反馈迭代。';
|
|
1084
|
+
}
|
|
1085
|
+
if (ctx === 'debate_judge') return '本轮先给出保守判定:双方都有可取点,建议继续下一轮交锋。';
|
|
1086
|
+
if (ctx === 'debate_speak') return '我方观点是先给可验证证据,再谈结论,避免空泛表态。';
|
|
1087
|
+
if (ctx === 'speaking') return '我补充一个观点:先对齐目标和约束,再讨论方案优先级。';
|
|
1088
|
+
if (String(promptText || '').includes('通过') && String(promptText || '').includes('不通过')) return '不通过';
|
|
1089
|
+
return '我先给一个简短结论:建议先明确目标,再执行并复盘。';
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function inferLegacyTurnContext(msg = {}) {
|
|
1093
|
+
const roomMode = String(msg.roomMode || '').trim().toLowerCase();
|
|
1094
|
+
const modePrompt = String(msg.modePrompt || '');
|
|
1095
|
+
if (msg.isHostComment) return 'host_comment';
|
|
1096
|
+
if (msg.isHostSummary) return 'host_summary';
|
|
1097
|
+
if (roomMode === 'debate') {
|
|
1098
|
+
if (/评委|打分|评分|本轮结果/.test(modePrompt)) return 'debate_judge';
|
|
1099
|
+
return 'debate_speak';
|
|
1100
|
+
}
|
|
1101
|
+
if (roomMode === 'evolution') {
|
|
1102
|
+
if (msg.parallelReview || /评审|可复用|宁可错杀/.test(modePrompt)) return 'evo_review';
|
|
1103
|
+
if (/只需回复\s*[“"]?通过[”"]?\s*或\s*[“"]?不通过/.test(modePrompt) || /通过.*不通过|不通过.*通过/.test(modePrompt)) {
|
|
1104
|
+
return 'evo_vote';
|
|
1105
|
+
}
|
|
1106
|
+
if (/没有.*Skill|改为分享|使用经验|真实经验/.test(modePrompt)) return 'evo_experience';
|
|
1107
|
+
return 'evo_share';
|
|
1108
|
+
}
|
|
1109
|
+
return 'speaking';
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1101
1112
|
function buildSkillShareDraft(skillName, rawContent) {
|
|
1102
1113
|
const name = String(skillName || '').trim() || '未命名Skill';
|
|
1103
1114
|
const text = String(rawContent || '').replace(/\r\n/g, '\n').trim();
|
|
@@ -1173,7 +1184,8 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1173
1184
|
send({ type: 'skill_picked', skillName, skillContent });
|
|
1174
1185
|
} catch (err) {
|
|
1175
1186
|
api.logger.error(`[roundtable] pick_skill 失败(${source}): ${err.message}`);
|
|
1176
|
-
|
|
1187
|
+
// 失败时必须降级到经验分享,避免服务端将本轮直接判定为“跳过”导致无发言
|
|
1188
|
+
send({ type: 'skill_picked', noSkill: true, reason: 'experience' });
|
|
1177
1189
|
}
|
|
1178
1190
|
}
|
|
1179
1191
|
|
|
@@ -1683,7 +1695,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1683
1695
|
const turnContext = String(msg.turnContext || '').trim();
|
|
1684
1696
|
const promptText = String(msg.text || '').trim();
|
|
1685
1697
|
if (!promptText) {
|
|
1686
|
-
send({ type: 'bot_reply', text:
|
|
1698
|
+
send({ type: 'bot_reply', text: buildPromptFallbackReply(turnContext, promptText) });
|
|
1687
1699
|
break;
|
|
1688
1700
|
}
|
|
1689
1701
|
if (turnContext === 'pick_skill') {
|
|
@@ -1691,21 +1703,22 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1691
1703
|
break;
|
|
1692
1704
|
}
|
|
1693
1705
|
try {
|
|
1694
|
-
const timeoutMs = turnContext
|
|
1695
|
-
? 22000
|
|
1696
|
-
: (turnContext === 'evo_share' || turnContext === 'evo_experience')
|
|
1697
|
-
? 42000
|
|
1698
|
-
: (turnContext === 'host_comment' ? 15000 : (turnContext === 'host_summary' ? 45000 : 65000));
|
|
1706
|
+
const timeoutMs = getPromptTimeoutMs(turnContext);
|
|
1699
1707
|
const reply = await callAIWithTimeout(promptText, timeoutMs, `prompt_${turnContext || 'generic'}`);
|
|
1700
|
-
|
|
1701
|
-
if (
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
if (turnContext === 'evo_vote') {
|
|
1705
|
-
send({ type: 'bot_reply', text: '不通过' });
|
|
1708
|
+
const normalized = String(reply || '').trim();
|
|
1709
|
+
if (normalized) {
|
|
1710
|
+
send({ type: 'bot_reply', text: normalized });
|
|
1711
|
+
rememberFact(`频道提示(${turnContext || 'generic'})已响应`);
|
|
1706
1712
|
} else {
|
|
1707
|
-
|
|
1713
|
+
const fallback = buildPromptFallbackReply(turnContext, promptText);
|
|
1714
|
+
api.logger.warn(`[roundtable] prompt(${turnContext || 'generic'}) 返回空文本,使用兜底回复`);
|
|
1715
|
+
send({ type: 'bot_reply', text: fallback });
|
|
1716
|
+
rememberFact(`频道提示(${turnContext || 'generic'})空回复,已兜底`);
|
|
1708
1717
|
}
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
api.logger.error(`[roundtable] prompt(${turnContext || 'generic'}) 失败: ${err.message}`);
|
|
1720
|
+
const fallback = buildPromptFallbackReply(turnContext, promptText);
|
|
1721
|
+
send({ type: 'bot_reply', text: fallback });
|
|
1709
1722
|
}
|
|
1710
1723
|
break;
|
|
1711
1724
|
}
|
|
@@ -1716,6 +1729,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1716
1729
|
);
|
|
1717
1730
|
send({ type: 'bot_status', status: 'speaking', text: `🧠 正在思考:${msg.topic || ''}` });
|
|
1718
1731
|
try {
|
|
1732
|
+
const turnContext = inferLegacyTurnContext(msg);
|
|
1719
1733
|
const reply = await generateReply(
|
|
1720
1734
|
api,
|
|
1721
1735
|
core,
|
|
@@ -1725,22 +1739,27 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1725
1739
|
msg,
|
|
1726
1740
|
getFactualContext(10)
|
|
1727
1741
|
);
|
|
1728
|
-
|
|
1729
|
-
|
|
1742
|
+
const normalized = String(reply || '').trim();
|
|
1743
|
+
if (normalized) {
|
|
1744
|
+
send({ type: "bot_reply", text: normalized });
|
|
1730
1745
|
api.logger.info(
|
|
1731
|
-
`[roundtable] ✅ 已发言: ${
|
|
1746
|
+
`[roundtable] ✅ 已发言: ${normalized.slice(0, 60)}...`
|
|
1732
1747
|
);
|
|
1733
|
-
rememberFact(`房间发言:${
|
|
1748
|
+
rememberFact(`房间发言:${normalized.slice(0, 80)}`);
|
|
1734
1749
|
} else {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1750
|
+
const fallback = buildPromptFallbackReply(turnContext, msg.modePrompt || msg.topic || '');
|
|
1751
|
+
send({ type: "bot_reply", text: fallback });
|
|
1752
|
+
api.logger.warn(`[roundtable] your_turn(${turnContext}) AI 返回空内容,已使用兜底回复`);
|
|
1753
|
+
rememberFact(`房间发言兜底(${turnContext})`);
|
|
1754
|
+
send({ type: 'bot_status', status: 'idle', text: '⚪ 本轮使用兜底回复' });
|
|
1738
1755
|
}
|
|
1739
1756
|
} catch (err) {
|
|
1757
|
+
const turnContext = inferLegacyTurnContext(msg);
|
|
1758
|
+
const fallback = buildPromptFallbackReply(turnContext, msg.modePrompt || msg.topic || '');
|
|
1740
1759
|
api.logger.error(
|
|
1741
1760
|
`[roundtable] AI 调用失败: ${err.message}`
|
|
1742
1761
|
);
|
|
1743
|
-
send({ type: "bot_reply", text:
|
|
1762
|
+
send({ type: "bot_reply", text: fallback });
|
|
1744
1763
|
send({ type: 'bot_status', status: 'error', text: `❌ AI 调用失败: ${err.message}` });
|
|
1745
1764
|
}
|
|
1746
1765
|
break;
|
|
@@ -1767,26 +1786,12 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
|
|
|
1767
1786
|
} else {
|
|
1768
1787
|
const next = remaining[0];
|
|
1769
1788
|
api.logger.info(`[roundtable] 🧬 改选: ${next}`);
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
`请输出可审核、可复用的 Skill 分享稿。`,
|
|
1777
|
-
`Skill:${next}(中文名建议:${chineseName || next})`,
|
|
1778
|
-
`只讲真实实践,不要编造,不要空话。`,
|
|
1779
|
-
`格式:`,
|
|
1780
|
-
`【Skill名称】中文名(英文名)`,
|
|
1781
|
-
`① 解决痛点 ② 核心步骤 ③ 实战案例 ④ 边界与风险`,
|
|
1782
|
-
`全中文,180字以内。`,
|
|
1783
|
-
].join('\n');
|
|
1784
|
-
const descReply = await callAI(api, core, myName, descPrompt);
|
|
1785
|
-
rememberFact(`改选分享 Skill:${finalSkillName}`);
|
|
1786
|
-
send({ type: 'skill_picked', skillName: finalSkillName, skillContent: descReply || `Skill: ${finalSkillName}` });
|
|
1787
|
-
} catch {
|
|
1788
|
-
send({ type: 'skill_picked', skillName: next, skillContent: `Skill: ${next}` });
|
|
1789
|
-
}
|
|
1789
|
+
const safeName = sanitizeSkillDirName(next);
|
|
1790
|
+
const skillFile = safeName ? pathModule.join(skillsRoot, safeName, 'SKILL.md') : '';
|
|
1791
|
+
const raw = skillFile ? readTextSafe(skillFile) : '';
|
|
1792
|
+
const draft = buildSkillShareDraft(next, raw);
|
|
1793
|
+
rememberFact(`改选分享 Skill:${next}`);
|
|
1794
|
+
send({ type: 'skill_picked', skillName: next, skillContent: draft });
|
|
1790
1795
|
}
|
|
1791
1796
|
break;
|
|
1792
1797
|
}
|
|
@@ -2086,12 +2091,24 @@ function callAIViaHTTP(prompt, maxTokens = 500, timeoutMs = 45000) {
|
|
|
2086
2091
|
let data = '';
|
|
2087
2092
|
res.on('data', (c) => (data += c));
|
|
2088
2093
|
res.on('end', () => {
|
|
2094
|
+
const status = Number(res.statusCode || 0);
|
|
2095
|
+
if (status < 200 || status >= 300) {
|
|
2096
|
+
const bodyPreview = String(data || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
2097
|
+
reject(new Error(`HTTP AI status ${status}${bodyPreview ? `: ${bodyPreview}` : ''}`));
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2089
2100
|
try {
|
|
2090
2101
|
const json = JSON.parse(data);
|
|
2091
2102
|
const text = json.choices?.[0]?.message?.content || '';
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2103
|
+
const normalized = String(text).trim();
|
|
2104
|
+
if (!normalized) {
|
|
2105
|
+
const errMsg = String(json?.error?.message || json?.error || '').trim();
|
|
2106
|
+
reject(new Error(errMsg ? `HTTP AI empty content: ${errMsg}` : 'HTTP AI empty content'));
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
resolve(normalized);
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
reject(new Error(`HTTP AI invalid JSON: ${err.message}`));
|
|
2095
2112
|
}
|
|
2096
2113
|
});
|
|
2097
2114
|
});
|
|
@@ -2133,17 +2150,21 @@ async function callAIViaRuntime(api, core, prompt, sessionLabel = '龙虾圆桌'
|
|
|
2133
2150
|
CommandAuthorized: true,
|
|
2134
2151
|
});
|
|
2135
2152
|
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2153
|
+
const finalParts = [];
|
|
2154
|
+
const blockParts = [];
|
|
2155
|
+
const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2139
2156
|
ctx: ctxPayload,
|
|
2140
2157
|
cfg: api.config,
|
|
2141
2158
|
dispatcherOptions: {
|
|
2142
|
-
deliver: async (payload) => {
|
|
2159
|
+
deliver: async (payload, info) => {
|
|
2143
2160
|
const text =
|
|
2144
2161
|
typeof payload?.text === "string" ? payload.text.trim() : "";
|
|
2145
2162
|
if (text) {
|
|
2146
|
-
|
|
2163
|
+
if (info?.kind === "block") {
|
|
2164
|
+
blockParts.push(text);
|
|
2165
|
+
} else {
|
|
2166
|
+
finalParts.push(text);
|
|
2167
|
+
}
|
|
2147
2168
|
}
|
|
2148
2169
|
},
|
|
2149
2170
|
onError: (err, info) => {
|
|
@@ -2154,7 +2175,16 @@ async function callAIViaRuntime(api, core, prompt, sessionLabel = '龙虾圆桌'
|
|
|
2154
2175
|
},
|
|
2155
2176
|
});
|
|
2156
2177
|
|
|
2157
|
-
|
|
2178
|
+
const finalReply = finalParts.join("\n").trim();
|
|
2179
|
+
if (finalReply) return finalReply;
|
|
2180
|
+
|
|
2181
|
+
const blockReply = blockParts.join("\n").trim();
|
|
2182
|
+
if (blockReply) return blockReply;
|
|
2183
|
+
|
|
2184
|
+
if (!dispatchResult?.queuedFinal) {
|
|
2185
|
+
throw new Error("runtime_no_reply_queued");
|
|
2186
|
+
}
|
|
2187
|
+
throw new Error("runtime_empty_reply");
|
|
2158
2188
|
}
|
|
2159
2189
|
|
|
2160
2190
|
/**
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/channel.js
CHANGED
|
@@ -16,7 +16,22 @@ const CHANNEL_ID = "lobster-roundtable";
|
|
|
16
16
|
|
|
17
17
|
function createOutboundMessageId() {
|
|
18
18
|
return `rt-outbound-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
19
|
-
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isExplicitConfigValue(value) {
|
|
22
|
+
if (value === undefined || value === null) return false;
|
|
23
|
+
if (typeof value === "string" && value.trim() === "") return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mergeConfigPreferExplicitChannel(pluginEntryConfig, channelConfig) {
|
|
28
|
+
const merged = { ...(pluginEntryConfig || {}) };
|
|
29
|
+
for (const [key, value] of Object.entries(channelConfig || {})) {
|
|
30
|
+
if (!isExplicitConfigValue(value)) continue;
|
|
31
|
+
merged[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return merged;
|
|
34
|
+
}
|
|
20
35
|
|
|
21
36
|
/**
|
|
22
37
|
* �?ChannelGatewayContext 适配�?main.js 所需�?api 兼容对象
|
|
@@ -27,12 +42,12 @@ function createOutboundMessageId() {
|
|
|
27
42
|
* - api.runtime �?ctx.runtime
|
|
28
43
|
* - api.config �?ctx.cfg (完�?OpenClawConfig,用�?dispatchReply �?cfg 参数�?
|
|
29
44
|
*/
|
|
30
|
-
function buildApiAdapter(ctx) {
|
|
31
|
-
// 双源读取:channels.<id>(官方标准路径)优先,plugins.entries
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const pluginConfig =
|
|
45
|
+
function buildApiAdapter(ctx) {
|
|
46
|
+
// 双源读取:channels.<id>(官方标准路径)优先,plugins.entries(兼容现有安装)降级。
|
|
47
|
+
// 关键:channels 中空字符串不覆盖 plugins.entries 中已有 token/url,避免“loaded 但离线”。
|
|
48
|
+
const channelConfig = ctx.cfg?.channels?.[CHANNEL_ID] || {};
|
|
49
|
+
const pluginEntryConfig = ctx.cfg?.plugins?.entries?.[CHANNEL_ID]?.config || {};
|
|
50
|
+
const pluginConfig = mergeConfigPreferExplicitChannel(pluginEntryConfig, channelConfig);
|
|
36
51
|
|
|
37
52
|
return {
|
|
38
53
|
// main.js �?api.logger.info/warn/error
|
|
@@ -76,12 +91,14 @@ const roundtablePlugin = {
|
|
|
76
91
|
reload: { configPrefixes: [`channels.${CHANNEL_ID}`, `plugins.entries.${CHANNEL_ID}`] },
|
|
77
92
|
|
|
78
93
|
config: {
|
|
79
|
-
listAccountIds: () => ["default"],
|
|
80
|
-
resolveAccount: (cfg, accountId) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
listAccountIds: () => ["default"],
|
|
95
|
+
resolveAccount: (cfg, accountId) => {
|
|
96
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
|
|
97
|
+
const pluginEntryConfig = cfg?.plugins?.entries?.[CHANNEL_ID]?.config || {};
|
|
98
|
+
const pluginCfg = mergeConfigPreferExplicitChannel(pluginEntryConfig, channelConfig);
|
|
99
|
+
return {
|
|
100
|
+
accountId: accountId || "default",
|
|
101
|
+
enabled: true,
|
|
85
102
|
// main.js 支持默认 URL + 自动注册,因此始终视�?configured
|
|
86
103
|
// 不拦截零配置启动,让 main.js 自己处理
|
|
87
104
|
configured: true,
|
package/src/fileio.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
|
|
5
|
+
function readTextSafe(filePath) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.existsSync(filePath) ? String(fs.readFileSync(filePath, "utf-8") || "").trim() : "";
|
|
8
|
+
} catch {
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function decodeFileBuffer(buf) {
|
|
14
|
+
if (!Buffer.isBuffer(buf) || buf.length === 0) return "";
|
|
15
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
16
|
+
return buf.slice(3).toString("utf8");
|
|
17
|
+
}
|
|
18
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
19
|
+
return buf.slice(2).toString("utf16le");
|
|
20
|
+
}
|
|
21
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
22
|
+
const swapped = Buffer.allocUnsafe(buf.length - 2);
|
|
23
|
+
for (let i = 2; i + 1 < buf.length; i += 2) {
|
|
24
|
+
swapped[i - 2] = buf[i + 1];
|
|
25
|
+
swapped[i - 1] = buf[i];
|
|
26
|
+
}
|
|
27
|
+
return swapped.toString("utf16le");
|
|
28
|
+
}
|
|
29
|
+
const sample = buf.slice(0, Math.min(buf.length, 128));
|
|
30
|
+
let nulOdd = 0;
|
|
31
|
+
for (let i = 1; i < sample.length; i += 2) {
|
|
32
|
+
if (sample[i] === 0) nulOdd++;
|
|
33
|
+
}
|
|
34
|
+
if (sample.length > 8 && nulOdd >= sample.length / 6) {
|
|
35
|
+
return buf.toString("utf16le");
|
|
36
|
+
}
|
|
37
|
+
return buf.toString("utf8");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJsonFileFlexible(filePath) {
|
|
41
|
+
const raw = fs.readFileSync(filePath);
|
|
42
|
+
let text = decodeFileBuffer(raw);
|
|
43
|
+
if (!text) return {};
|
|
44
|
+
text = String(text).replace(/^\uFEFF/, "").trim();
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
const first = text.indexOf("{");
|
|
49
|
+
const last = text.lastIndexOf("}");
|
|
50
|
+
if (first >= 0 && last > first) {
|
|
51
|
+
const candidate = text.slice(first, last + 1).trim();
|
|
52
|
+
return JSON.parse(candidate);
|
|
53
|
+
}
|
|
54
|
+
throw new Error("invalid_json");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
readTextSafe,
|
|
60
|
+
parseJsonFileFlexible,
|
|
61
|
+
};
|