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 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
- "/icons/typesafe.png": ("icons/typesafe.png", "image/png")}
96
- ASSETS.update({f"/icons/{name}.svg": (f"icons/{name}.svg", "image/svg+xml")
97
- for name in ("openai", "claude", "gemini", "deepseek", "qwen", "user")})
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -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
- return send_notice(this.home, { ...notice, text: link ? `${notice.text}
315
- 打开房间:${link}(需在已配对的设备上打开)` : notice.text });
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
  }
@@ -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
- const question = notice.goal.phase_note ? `主持人在等你确认:
585
- ${notice.goal.phase_note}` : await this.latest_excerpt(room);
586
- const text = `${question}
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 `房间在等你回复,最近一条是 ${latest.author} 说:
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)) {
@@ -304,13 +304,16 @@ function local_start(home, key, request) {
304
304
  ready: `${LOCAL_URL}/v1/status` }, request);
305
305
  }
306
306
 
307
- /** Convert the WAV to 64 kbps AAC with the converter yovoice ships, which reads and writes files only. */
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
- // Every notice starts with this tag; quoting a notice in Weixin sends the reply to that notice's room.
27
- const NOTICE_TAG = /\[CoDiscuss #(\d+)\]/;
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 has not messaged the bot since binding).
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, text }, client_options = {}) {
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 = `[CoDiscuss #${number}]${room?.title ? ` ${room.title}` : ''}\n${text}`;
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() }].slice(-RECENT_NOTICES);
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
- .map(reference => `${reference.message_item?.text_item?.text ?? ''}\n${reference.title ?? ''}`.match(NOTICE_TAG)?.[1]).find(Boolean);
262
- texts.push({ text, quoted: quoted ? Number(quoted) : 0, media: items.some(item => item.type !== 1), context_token: message.context_token });
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
- /** The room a reply answers: the quoted notice's, else the latest notice that came from a room. */
268
- function reply_target(state, quoted) {
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
- return (quoted && rooms.find(notice => notice.number === quoted)) || rooms.at(-1) || null;
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, { text: '这是一条测试通知。房间里的 Goal 等你确认时,会像这样发到微信;直接回复即可发回该房间。' });
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) };