ai_co_discuss 0.4.11 → 0.4.12
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/backend/server.py +7 -4
- package/package.json +1 -1
- package/runner/discussion_service.mjs +2 -2
- package/runner/free_runner.mjs +6 -10
- package/runner/tts.mjs +2 -1
- package/runner/tts_providers.mjs +5 -2
- package/runner/weixin.mjs +70 -17
- package/web/modern.js +1 -1
package/backend/server.py
CHANGED
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import hmac
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
|
+
import mimetypes
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
12
13
|
import secrets
|
|
@@ -91,10 +92,12 @@ ASSETS = {"/": ("index.html", "text/html"),
|
|
|
91
92
|
"/markdown.js": ("markdown.js", "text/javascript"),
|
|
92
93
|
"/backup.js": ("backup.js", "text/javascript"),
|
|
93
94
|
"/markdown.css": ("markdown.css", "text/css"),
|
|
94
|
-
"/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
"/icons/LICENSE.txt": ("icons/LICENSE.txt", "text/plain")}
|
|
96
|
+
# Serve bundled images without maintaining a second list of brand names.
|
|
97
|
+
for icon in (WEB_ROOT / "icons").iterdir():
|
|
98
|
+
icon_type = mimetypes.guess_type(icon.name)[0] or ""
|
|
99
|
+
if icon.is_file() and not icon.name.startswith(".") and icon_type.startswith("image/"):
|
|
100
|
+
ASSETS[f"/icons/{icon.name}"] = (f"icons/{icon.name}", icon_type)
|
|
98
101
|
ASSETS.update({"/pdfjs/" + file.relative_to(WEB_ROOT / "pdfjs").as_posix():
|
|
99
102
|
(file.relative_to(WEB_ROOT).as_posix(),
|
|
100
103
|
{".mjs": "text/javascript", ".js": "text/javascript", ".wasm": "application/wasm"}.get(file.suffix, "application/octet-stream"))
|
package/package.json
CHANGED
|
@@ -311,8 +311,8 @@ class RoomWorker {
|
|
|
311
311
|
if ('notifier' in this.runner) this.runner.notifier = { ready: () => can_notify(this.home), sent: key => notice_sent(this.home, key),
|
|
312
312
|
send: async notice => {
|
|
313
313
|
const link = await phone_link(this.home, notice.room.site, notice.room.room);
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
const opening = link ? [`打开房间(需在已配对的设备上):${link}`] : [];
|
|
315
|
+
return send_notice(this.home, { ...notice, paragraphs: [...notice.paragraphs, ...opening] });
|
|
316
316
|
} };
|
|
317
317
|
Object.assign(this.state, { site: config.site, room: config.room, config_version: snapshot.version });
|
|
318
318
|
}
|
package/runner/free_runner.mjs
CHANGED
|
@@ -581,13 +581,10 @@ export class FreeRoomRunner {
|
|
|
581
581
|
if (!notice || Date.now() < notice.due || Date.now() - (this.notice_failed_at ?? 0) < NOTICE_RETRY_MS) return;
|
|
582
582
|
if (!this.notifier.ready() || this.notifier.sent(notice.key)) return;
|
|
583
583
|
try {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
Goal:${notice.goal.objective}
|
|
589
|
-
直接回复这条消息,会作为你的发言发到这个房间。`;
|
|
590
|
-
const result = await this.notifier.send({ key: notice.key, room: { site: this.config.site, room: this.config.room, title: room.title }, text });
|
|
584
|
+
// Weixin folds single line breaks, so each part is its own paragraph.
|
|
585
|
+
const question = notice.goal.phase_note ? ['主持人在等你确认:', notice.goal.phase_note] : await this.latest_excerpt(room);
|
|
586
|
+
const paragraphs = [...question, `目标:${notice.goal.objective}`, '直接回复这条消息,会作为你的发言发回这个房间。'];
|
|
587
|
+
const result = await this.notifier.send({ key: notice.key, room: { site: this.config.site, room: this.config.room, title: room.title }, paragraphs });
|
|
591
588
|
if (result.status === 'sent') this.log(`Goal 待确认已发微信通知 #${result.number}`);
|
|
592
589
|
} catch (error) {
|
|
593
590
|
this.notice_failed_at = Date.now();
|
|
@@ -598,10 +595,9 @@ Goal:${notice.goal.objective}
|
|
|
598
595
|
async latest_excerpt(room) {
|
|
599
596
|
const page = await api(this.config, `/messages?after=${Math.max(0, (room.latest_id || 0) - 5)}`);
|
|
600
597
|
const latest = (page.messages || []).filter(message => message.author !== '系统').at(-1);
|
|
601
|
-
if (!latest) return '房间在等你的决定或补充信息,详情见房间。';
|
|
598
|
+
if (!latest) return ['房间在等你的决定或补充信息,详情见房间。'];
|
|
602
599
|
const text = latest.text.length > NOTICE_EXCERPT ? `${latest.text.slice(0, NOTICE_EXCERPT)}…` : latest.text;
|
|
603
|
-
return
|
|
604
|
-
${text}`;
|
|
600
|
+
return [`房间在等你回复。最近一条是 ${latest.author} 说:`, text];
|
|
605
601
|
}
|
|
606
602
|
/** Ask the decision model whether the idle goal room needs its host, from the latest messages and who is in it. */
|
|
607
603
|
async judge_goal(room, goal) {
|
package/runner/tts.mjs
CHANGED
|
@@ -156,8 +156,9 @@ export async function tts_speak(home, input, transport) {
|
|
|
156
156
|
const silence = Math.max(0, Math.min(MAX_SILENCE_MS, Math.round(Number(input.silence_ms) || 0)));
|
|
157
157
|
const context = provider.context ? settings.context : '';
|
|
158
158
|
const directory = join(home, CACHE_DIR);
|
|
159
|
+
// Local audio cached before 2026-11-10 has its index at the end, which iPhone Safari cannot play; a new key leaves it unused.
|
|
159
160
|
const cache_path = join(directory, `${createHash('sha256').update(JSON.stringify([text, speaker, settings.speech_rate, context, silence,
|
|
160
|
-
...(provider.id === 'volcengine' ? [] : [provider.id])])).digest('hex')}.json`);
|
|
161
|
+
...(provider.id === 'volcengine' ? [] : [provider.local ? 'local-faststart' : provider.id])])).digest('hex')}.json`);
|
|
161
162
|
const cached = existsSync(cache_path) && cache_read(cache_path);
|
|
162
163
|
if (cached?.audio) return { ...cached, cached: true };
|
|
163
164
|
if (!in_flight.has(cache_path)) {
|
package/runner/tts_providers.mjs
CHANGED
|
@@ -304,13 +304,16 @@ function local_start(home, key, request) {
|
|
|
304
304
|
ready: `${LOCAL_URL}/v1/status` }, request);
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Convert the WAV to 64 kbps AAC with the converter yovoice ships, which reads and writes files only. The index (moov)
|
|
309
|
+
* goes before the audio: iPhone Safari rejects a blob with it at the end ("The operation is not supported.").
|
|
310
|
+
*/
|
|
308
311
|
async function local_convert(home, wav) {
|
|
309
312
|
const work = mkdtempSync(join(tmpdir(), 'codiscuss-tts-'));
|
|
310
313
|
try {
|
|
311
314
|
writeFileSync(join(work, 'in.wav'), wav);
|
|
312
315
|
await run_file(join(home, LOCAL_DIR, 'cli', 'tools', `ffmpeg${LOCAL_EXE}`), ['-hide_banner', '-loglevel', 'error', '-i', join(work, 'in.wav'),
|
|
313
|
-
'-c:a', 'aac', '-b:a', LOCAL_BIT_RATE, '-f', 'ipod', join(work, 'out.m4a')], { windowsHide: true, timeout: LOCAL_CONVERT_MS })
|
|
316
|
+
'-c:a', 'aac', '-b:a', LOCAL_BIT_RATE, '-movflags', '+faststart', '-f', 'ipod', join(work, 'out.m4a')], { windowsHide: true, timeout: LOCAL_CONVERT_MS })
|
|
314
317
|
.catch(() => { throw new Error('本机语音转码失败:请重新运行 local-tts-setup 技能修复安装'); });
|
|
315
318
|
return readFileSync(join(work, 'out.m4a'));
|
|
316
319
|
} finally { rmSync(work, { recursive: true, force: true }); }
|
package/runner/weixin.mjs
CHANGED
|
@@ -23,8 +23,8 @@ const RECEIVE_RETRY_MS = 10_000;
|
|
|
23
23
|
const UNBOUND_CHECK_MS = 5_000;
|
|
24
24
|
const CHUNK_BYTES = 3_500;
|
|
25
25
|
const RECENT_NOTICES = 50;
|
|
26
|
-
//
|
|
27
|
-
const
|
|
26
|
+
// Messages name their source first, as coder_notifier's do (`💬 Codex(…):`), since one Weixin may get both.
|
|
27
|
+
const SOURCE = 'CoDiscuss';
|
|
28
28
|
|
|
29
29
|
/** An explicit API rejection, distinguishable from an uncertain transport outcome. */
|
|
30
30
|
export class IlinkError extends Error {
|
|
@@ -129,6 +129,44 @@ export class IlinkClient {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Weixin shows plain text only: turn common Markdown into readable text, keeping code and link targets. Copied from
|
|
134
|
+
* coder_notifier's `weixin_text`.
|
|
135
|
+
*/
|
|
136
|
+
export function weixin_text(text) {
|
|
137
|
+
const code = [];
|
|
138
|
+
const marker = `\uE000${randomUUID()}:`;
|
|
139
|
+
let plain = text.replace(/```[^\n]*\n([\s\S]*?)```|`([^`\n]+)`/g, (_match, block, inline) => {
|
|
140
|
+
code.push(block ?? inline); return `${marker}${code.length - 1}\uE001`;
|
|
141
|
+
});
|
|
142
|
+
plain = plain.replace(/!?(?:\[([^\]\n]*)\])\(([^)\n]+)\)/g,
|
|
143
|
+
(_match, label, url) => label === url ? url : `${label || '链接'}(${url})`)
|
|
144
|
+
.replace(/^ {0,3}#{1,6}\s+(.+?)(?:\s+#+)?$/gm, '$1')
|
|
145
|
+
.replace(/\*\*([^\n]+?)\*\*|__([^\n]+?)__|~~([^\n]+?)~~/g, (_match, bold, under, strike) => bold ?? under ?? strike)
|
|
146
|
+
.replace(/(^|\s)\*([^*\n]+)\*(?=\s|[,。!?,.!?]|$)/g, '$1$2')
|
|
147
|
+
.replace(/(^|\s)_([^_\n]+)_(?=\s|[,。!?,.!?]|$)/g, '$1$2')
|
|
148
|
+
.replace(/^[\t ]*[-*+][\t ]+/gm, '• ').replace(/^[\t ]*>[\t ]?/gm, '')
|
|
149
|
+
.replace(/^[\t ]*(?:---+|\*\*\*+|___+)[\t ]*$/gm, '')
|
|
150
|
+
.replace(/^[\t ]*\|?[\t ]*:?-{3,}:?[\t ]*(?:\|[\t ]*:?-{3,}:?[\t ]*)+\|?[\t ]*$/gm, '')
|
|
151
|
+
.replace(/^[\t ]*\|(.+)\|[\t ]*$/gm, (_match, row) => row.split('|').map(cell => cell.trim()).join(';'));
|
|
152
|
+
code.forEach((value, index) => { plain = plain.replaceAll(`${marker}${index}\uE001`, value); });
|
|
153
|
+
return plain.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A notice as Weixin shows it: the source and room on the first line, a blank line, then plain-text paragraphs.
|
|
158
|
+
* Weixin folds single line breaks into spaces, so `paragraphs` are joined by blank lines.
|
|
159
|
+
*/
|
|
160
|
+
export function format_notice(title, paragraphs) {
|
|
161
|
+
const body = paragraphs.filter(Boolean).map(part => weixin_text(part)).join('\n\n');
|
|
162
|
+
return `💬 ${SOURCE}${title ? `(${title})` : ''}:\n\n${body}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A reply the receiver writes itself, not from a room. */
|
|
166
|
+
export function system_text(text) {
|
|
167
|
+
return `🔔 ${SOURCE} 系统通知:\n\n${text}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
132
170
|
/** Split text on code points so each chunk stays under iLink's payload size. */
|
|
133
171
|
export function split_text(text, limit = CHUNK_BYTES) {
|
|
134
172
|
const chunks = [];
|
|
@@ -223,10 +261,11 @@ export function unbind(home) {
|
|
|
223
261
|
}
|
|
224
262
|
|
|
225
263
|
/**
|
|
226
|
-
* Send one notice to the owner. `key` makes it idempotent; `room` ({site, room, title}) is where replies to it go
|
|
227
|
-
* Returns `sent`, `duplicate`, `unbound` or `no_context` (the owner
|
|
264
|
+
* Send one notice to the owner. `key` makes it idempotent; `room` ({site, room, title}) is where replies to it go;
|
|
265
|
+
* `paragraphs` are shown separated by blank lines. Returns `sent`, `duplicate`, `unbound` or `no_context` (the owner
|
|
266
|
+
* has not messaged the bot since binding).
|
|
228
267
|
*/
|
|
229
|
-
export async function send_notice(home, { key, room = null,
|
|
268
|
+
export async function send_notice(home, { key, room = null, paragraphs }, client_options = {}) {
|
|
230
269
|
const account = weixin_account(home);
|
|
231
270
|
if (!account) return { status: 'unbound' };
|
|
232
271
|
const state = read_state(home);
|
|
@@ -234,17 +273,21 @@ export async function send_notice(home, { key, room = null, text }, client_optio
|
|
|
234
273
|
if (!state.context_token) return { status: 'no_context' };
|
|
235
274
|
const number = state.next_notice;
|
|
236
275
|
const client = new IlinkClient(account, client_options);
|
|
237
|
-
const body =
|
|
276
|
+
const body = format_notice(room?.title, paragraphs);
|
|
277
|
+
// Weixin's message ids let a quoted reply find its notice without showing any number to the owner.
|
|
278
|
+
const server_ids = [];
|
|
238
279
|
try {
|
|
239
280
|
for (const chunk of split_text(body)) {
|
|
240
|
-
await client.send({ peer_id: account.owner_id, client_id: randomUUID(), context_token: state.context_token, text: chunk });
|
|
281
|
+
const result = await client.send({ peer_id: account.owner_id, client_id: randomUUID(), context_token: state.context_token, text: chunk });
|
|
282
|
+
if (String(result.message_id ?? '').trim()) server_ids.push(String(result.message_id));
|
|
241
283
|
}
|
|
242
284
|
} catch (error) {
|
|
243
285
|
write_json(state_path(home), { ...read_state(home), last_error: error.message });
|
|
244
286
|
throw error;
|
|
245
287
|
}
|
|
246
288
|
const latest = read_state(home);
|
|
247
|
-
latest.notices = [...latest.notices, { number, key: key || '', site: room?.site || '', room: room?.room || '', sent_at: Date.now()
|
|
289
|
+
latest.notices = [...latest.notices, { number, key: key || '', site: room?.site || '', room: room?.room || '', sent_at: Date.now(),
|
|
290
|
+
server_ids, text: body }].slice(-RECENT_NOTICES);
|
|
248
291
|
write_json(state_path(home), { ...latest, next_notice: number + 1, last_error: '' });
|
|
249
292
|
return { status: 'sent', number };
|
|
250
293
|
}
|
|
@@ -257,17 +300,26 @@ export function owner_texts(account, response) {
|
|
|
257
300
|
|| (message.to_user_id && message.to_user_id !== account.account_id)) continue;
|
|
258
301
|
const items = message.item_list ?? [];
|
|
259
302
|
const text = items.filter(item => item.type === 1 && typeof item.text_item?.text === 'string').map(item => item.text_item.text).join('\n').trim();
|
|
260
|
-
const quoted = items.map(item => item.ref_msg).filter(Boolean)
|
|
261
|
-
|
|
262
|
-
|
|
303
|
+
const quoted = items.map(item => item.ref_msg).filter(Boolean).map(reference => ({
|
|
304
|
+
server_id: reference.svr_id == null ? '' : String(reference.svr_id),
|
|
305
|
+
text: (reference.message_item?.text_item?.text ?? reference.title ?? '').trim() }));
|
|
306
|
+
texts.push({ text, quoted, media: items.some(item => item.type !== 1), context_token: message.context_token });
|
|
263
307
|
}
|
|
264
308
|
return texts;
|
|
265
309
|
}
|
|
266
310
|
|
|
267
|
-
/**
|
|
268
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The room a reply answers: the quoted notice's (found by Weixin's message id, else by its text), else the latest
|
|
313
|
+
* notice that came from a room.
|
|
314
|
+
*/
|
|
315
|
+
function reply_target(state, quoted = []) {
|
|
269
316
|
const rooms = state.notices.filter(notice => notice.room);
|
|
270
|
-
|
|
317
|
+
for (const reference of quoted) {
|
|
318
|
+
const found = rooms.findLast(notice => (reference.server_id && notice.server_ids?.includes(reference.server_id))
|
|
319
|
+
|| (reference.text && notice.text?.includes(reference.text)));
|
|
320
|
+
if (found) return found;
|
|
321
|
+
}
|
|
322
|
+
return rooms.at(-1) || null;
|
|
271
323
|
}
|
|
272
324
|
|
|
273
325
|
/**
|
|
@@ -303,13 +355,13 @@ async function answer(home, account, client, item, on_reply, log) {
|
|
|
303
355
|
const target = reply_target(state, item.quoted);
|
|
304
356
|
let reply;
|
|
305
357
|
if (!item.text) reply = item.media ? '目前只能转发文字回复。' : '';
|
|
306
|
-
else if (!target?.room) reply = '
|
|
358
|
+
else if (!target?.room) reply = '已收到。还没有房间给你发过通知,这条回复没有可发往的房间。';
|
|
307
359
|
else {
|
|
308
360
|
try { reply = await on_reply({ site: target.site, room: target.room, text: item.text }); }
|
|
309
361
|
catch (error) { reply = `未能发到房间:${error.message}`; log(`微信回复未能发到房间 ${target.room}:${error.message}`); }
|
|
310
362
|
}
|
|
311
363
|
if (!reply || !state.context_token) return;
|
|
312
|
-
try { await client.send({ peer_id: account.owner_id, client_id: randomUUID(), context_token: state.context_token, text: reply }); }
|
|
364
|
+
try { await client.send({ peer_id: account.owner_id, client_id: randomUUID(), context_token: state.context_token, text: system_text(reply) }); }
|
|
313
365
|
catch (error) { log(`微信回执发送失败:${error.message}`); }
|
|
314
366
|
}
|
|
315
367
|
|
|
@@ -331,7 +383,8 @@ export async function weixin_command(home, input = {}) {
|
|
|
331
383
|
if (action === 'complete') return { ...await complete_login(home), ...weixin_status(home) };
|
|
332
384
|
if (action === 'unbind') return unbind(home);
|
|
333
385
|
if (action === 'test') {
|
|
334
|
-
const result = await send_notice(home, {
|
|
386
|
+
const result = await send_notice(home, { room: { title: '测试' }, paragraphs: ['这是一条测试通知。',
|
|
387
|
+
'房间里的 Goal 等你确认时,会像这样发到微信。直接回复通知,就会作为你的发言发回那个房间。'] });
|
|
335
388
|
if (result.status === 'no_context') throw new Error('还收不到你的消息:请先在微信里给机器人发一条消息,再点发送测试');
|
|
336
389
|
if (result.status === 'unbound') throw new Error('尚未绑定微信');
|
|
337
390
|
return { ...result, ...weixin_status(home) };
|