@quecue/dev-notify 0.1.2 → 0.1.4

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.
@@ -1,13 +1,14 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { setTimeout as delay } from 'node:timers/promises';
3
3
  import { ConfigError } from './config.js';
4
+ import { t } from './i18n.js';
4
5
  import { stateTransaction, targetEpoch } from './reply-state.js';
5
6
  import { replyHint } from './reply-telegram.js';
6
7
  import { finishInteraction } from './reply-outcomes.js';
7
- export const INTERACTION_MS = 5 * 60 * 1000;
8
+ export const INTERACTION_MS = 10 * 60 * 1000;
8
9
  export function questionsFrom(input) {
9
10
  if (!Array.isArray(input.questions) || input.questions.length < 1 || input.questions.length > 4)
10
- throw new ConfigError('问题结构无效,请在本机回答。');
11
+ throw new ConfigError(t('reply.invalidQuestions'));
11
12
  const questions = input.questions;
12
13
  const seen = new Set();
13
14
  for (const q of questions) {
@@ -15,7 +16,7 @@ export function questionsFrom(input) {
15
16
  || typeof q.header !== 'string' || (q.multiSelect !== undefined && typeof q.multiSelect !== 'boolean') || !Array.isArray(q.options)
16
17
  || q.options.length < 2 || q.options.length > 4 || q.options.some(o => !o || typeof o.label !== 'string' || !o.label.trim()
17
18
  || typeof o.description !== 'string') || new Set(q.options.map(o => o.label)).size !== q.options.length) {
18
- throw new ConfigError('问题结构无效,请在本机回答。');
19
+ throw new ConfigError(t('reply.invalidQuestions'));
19
20
  }
20
21
  seen.add(q.question);
21
22
  }
@@ -23,7 +24,7 @@ export function questionsFrom(input) {
23
24
  }
24
25
  export async function createInteraction(access, target, kind, input, call) {
25
26
  if (kind === 'permission' && access.policy.permissions !== true)
26
- throw new ConfigError('未授权远程处理工具权限。');
27
+ throw new ConfigError(t('reply.permissionsNotAuthorized'));
27
28
  const questions = kind === 'question' ? questionsFrom(input) : [];
28
29
  const value = { id: randomBytes(16).toString('hex'), session: target.session, epoch: targetEpoch(target),
29
30
  updated: Date.now(), expires: Date.now() + INTERACTION_MS, kind, input: structuredClone(input), questions,
@@ -31,18 +32,18 @@ export async function createInteraction(access, target, kind, input, call) {
31
32
  await stateTransaction(access, state => {
32
33
  const current = state.sessions[target.session];
33
34
  if (!current || targetEpoch(current) !== value.epoch)
34
- throw new ConfigError('会话目标发生了变化。');
35
+ throw new ConfigError(t('reply.targetChanged'));
35
36
  if (call) {
36
37
  const bound = state.calls[call];
37
38
  if (!bound || bound.ended || bound.request || bound.epoch !== value.epoch)
38
- throw new ConfigError('调用已经结束或已绑定,没有创建远程问题。');
39
+ throw new ConfigError(t('reply.callUnavailable'));
39
40
  bound.request = value.id;
40
41
  }
41
42
  // 不同 tool_use_id 可并行;无绑定的单元级调用仍只保留一个活动交互。
42
43
  if (!call)
43
44
  for (const old of Object.values(state.requests))
44
45
  if (old.session === target.session && !old.result) {
45
- finishInteraction(old, { kind: old.claimed ? 'unknown' : 'not-applied', text: '问题已切换,旧请求不再接受答案。' });
46
+ finishInteraction(old, { kind: old.claimed ? 'unknown' : 'not-applied', reason: 'switched', text: t('reply.questionSwitched') });
46
47
  }
47
48
  state.requests[value.id] = value;
48
49
  state.entries['token:' + value.id] = { session: target.session, epoch: value.epoch, request: value.id, updated: Date.now() };
@@ -56,51 +57,51 @@ export function interactionMarkup(value) {
56
57
  const prefix = `dn:q:${value.id}:${value.index}:`;
57
58
  const buttons = [];
58
59
  if (value.kind === 'permission') {
59
- buttons.push([{ text: '✅ 本次允许', callback_data: prefix + 'a' }, { text: '⛔ 拒绝', callback_data: prefix + 'd' }]);
60
- buttons.push([{ text: '✍️ 自定义拒绝说明', callback_data: prefix + 'c' }]);
60
+ buttons.push([{ text: t('reply.allowButton'), callback_data: prefix + 'a' }, { text: t('reply.denyButton'), callback_data: prefix + 'd' }]);
61
+ buttons.push([{ text: t('reply.customDenyButton'), callback_data: prefix + 'c' }]);
61
62
  }
62
63
  else {
63
64
  const q = value.questions[value.index];
64
65
  q.options.forEach((option, index) => buttons.push([{ text: (value.selected.includes(index) ? '☑ ' : '') + Array.from(option.label).slice(0, 60).join(''), callback_data: prefix + 'o' + index }]));
65
66
  if (q.multiSelect)
66
- buttons.push([{ text: '提交所选项', callback_data: prefix + 's' }]);
67
- buttons.push([{ text: '✍️ 自定义输入', callback_data: prefix + 'c' }]);
67
+ buttons.push([{ text: t('reply.submitSelected'), callback_data: prefix + 's' }]);
68
+ buttons.push([{ text: t('reply.customInput'), callback_data: prefix + 'c' }]);
68
69
  }
69
- buttons.push([{ text: '关闭远程答题(本机仍可操作)', callback_data: prefix + 'l' }]);
70
+ buttons.push([{ text: t('reply.closeRemote'), callback_data: prefix + 'l' }]);
70
71
  return { inline_keyboard: buttons };
71
72
  }
72
73
  export function interactionContent(value) {
73
74
  if (value.kind === 'permission')
74
- return '工具:' + String(value.input.tool_name || '未知') + '\n\n' + JSON.stringify(value.input.tool_input ?? {}, null, 2)
75
- + '\n\n这里处理本次的允许、拒绝或自定义拒绝说明。Claude 没有提供完整的本地按钮列表,因此这里也不会创建永久权限规则。';
75
+ return t('reply.toolLabel', { tool: String(value.input.tool_name || t('reply.unknownTool')) }) + '\n\n' + JSON.stringify(value.input.tool_input ?? {}, null, 2)
76
+ + '\n\n' + t('reply.permissionNote');
76
77
  const q = value.questions[value.index];
77
- return `问题 ${value.index + 1}/${value.questions.length}:${q.question}\n\n`
78
+ return t('reply.questionHeading', { index: value.index + 1, total: value.questions.length, question: q.question }) + '\n\n'
78
79
  + q.options.map((o, i) => `${i + 1}. ${o.label}\n${o.description}${o.preview ? '\n' + o.preview : ''}`).join('\n\n')
79
- + (q.multiSelect ? '\n\n可多选,选好后点击「提交所选项」;也可自定义输入。' : '\n\n点击选项直接回答,或选择自定义输入。')
80
- + '\n本机原生窗口也可以同时作答;Claude 先收到的完整答案生效。远程窗口有效期为 5 分钟,不会自动选择。';
80
+ + (q.multiSelect ? t('reply.multiSelectHint') : t('reply.singleSelectHint'))
81
+ + t('reply.localWindowNote');
81
82
  }
82
83
  // 飞书卡片没有回调按钮,只展示全部题目和跳转入口;逐题编号并空行分隔,避免题目与选项粘连。
83
84
  export function questionsContent(questions) {
84
- return questions.map((q, index) => `问题 ${index + 1}/${questions.length}:${q.question}\n\n`
85
+ return questions.map((q, index) => t('reply.questionHeading', { index: index + 1, total: questions.length, question: q.question }) + '\n\n'
85
86
  + q.options.map((o, i) => `${i + 1}. ${o.label}\n${o.description}${o.preview ? '\n' + o.preview : ''}`).join('\n\n')
86
- + (q.multiSelect ? '\n\n可多选:进入 Telegram 后逐项选择,再点击「提交所选项」;也可自定义输入。'
87
- : '\n\n进入 Telegram 后点击选项直接回答,或选择自定义输入。')).join('\n\n────────\n\n');
87
+ + (q.multiSelect ? t('reply.feishuMultiSelectHint')
88
+ : t('reply.feishuSingleSelectHint'))).join('\n\n────────\n\n');
88
89
  }
89
90
  export async function showInteraction(access, id, chat, api) {
90
91
  const value = await stateTransaction(access, state => state.requests[id]);
91
92
  if (!active(value)) {
92
- await replyHint(api, chat, '⚠️ 交互已失效', '请从最新通知进入,或回到本机处理。');
93
+ await replyHint(api, chat, t('reply.inactiveTitle'), t('reply.inactiveBody'));
93
94
  return;
94
95
  }
95
96
  // 使用通知分段器发送较长选项,所有选项正文保留,只有最后一段附选择按钮。
96
97
  const { makePlan, deliver } = await import('./channels.js');
97
98
  const { config } = access;
98
- const plan = makePlan('telegram', { title: 'Claude Code 需要你处理', content: interactionContent(value), footer: '', level: 'warn' }, { ...config, telegram: { ...config.telegram, chatIds: [chat] } });
99
+ const plan = makePlan('telegram', { title: t('hook.notificationTitle'), content: interactionContent(value), footer: '', level: 'warn' }, { ...config, telegram: { ...config.telegram, chatIds: [chat] } });
99
100
  plan.items[plan.items.length - 1].payload.reply_markup = interactionMarkup(value);
100
101
  plan.onSent = async (item, response) => {
101
102
  const message = response.result?.message_id;
102
103
  if (!Number.isSafeInteger(message))
103
- throw new ConfigError('交互通知没有返回消息 ID。');
104
+ throw new ConfigError(t('reply.noInteractionMessageId'));
104
105
  await stateTransaction(access, state => {
105
106
  const current = state.requests[id];
106
107
  if (current) {
@@ -119,7 +120,7 @@ export async function showInteraction(access, id, chat, api) {
119
120
  // API 封装由测试或真实桥接注入,不另建网络连接。
120
121
  const result = await deliver(plan, config, async (_url, payload) => ({ ok: true, result: await api('sendMessage', payload) }));
121
122
  if (!result.ok)
122
- throw new ConfigError('交互通知没有完整发送,请回到本机处理。');
123
+ throw new ConfigError(t('reply.interactionNotSent'));
123
124
  await stateTransaction(access, state => {
124
125
  const current = state.requests[id];
125
126
  if (!current)
@@ -152,7 +153,7 @@ export async function handleInteractionCallback(access, callback, chat, api) {
152
153
  if (!active(value) || value.index !== Number(index) || !target || targetEpoch(target) !== value.epoch)
153
154
  return 'expired';
154
155
  if (operation === 'l') {
155
- finishInteraction(value, { kind: 'not-applied', text: '已关闭远程答题,本机仍可正常处理。' });
156
+ finishInteraction(value, { kind: 'not-applied', reason: 'closed', text: t('reply.closedLocal') });
156
157
  return 'local';
157
158
  }
158
159
  const question = value.questions[value.index]?.question;
@@ -188,17 +189,17 @@ export async function handleInteractionCallback(access, callback, chat, api) {
188
189
  return operation === 'c' ? 'custom' : value.response ? 'done' : value.index !== previousIndex ? 'answered' : 'next';
189
190
  });
190
191
  await api('answerCallbackQuery', { callback_query_id: callback.id,
191
- text: outcome === 'expired' ? '没有提交:按钮已失效,或已有答案等待确认。' : outcome === 'local' ? '已关闭远程答题,本机不受影响。' : outcome === 'done' || outcome === 'answered' ? '答案已登记,尚未确认生效。' : '已更新。' }).catch(() => { });
192
+ text: outcome === 'expired' ? t('reply.callbackExpired') : outcome === 'local' ? t('reply.callbackClosed') : outcome === 'done' || outcome === 'answered' ? t('reply.callbackRegistered') : t('reply.callbackUpdated') }).catch(() => { });
192
193
  if (outcome === 'done' || outcome === 'answered')
193
- await replyHint(api, chat, '⏳ 答案已登记,等待确认', (outcome === 'answered' ? '仍需完成其余问题,之后才回传整份答案。' : '已进入回传流程,Claude 是否采用会在稍后反馈。')
194
- + '\n稍后会反馈结果;如果一直没有收到,请检查终端。\n请求:' + id.slice(0, 8)).catch(() => { });
194
+ await replyHint(api, chat, t('reply.pendingConfirmationTitle'), (outcome === 'answered' ? t('reply.pendingRemaining') : t('reply.pendingSubmitted'))
195
+ + t('reply.pendingFooter', { id: id.slice(0, 8) })).catch(() => { });
195
196
  if (outcome === 'expired')
196
- await replyHint(api, chat, '⚠️ 本次提交未生效', '这次操作没有提交:问题已处理、过期、题目已切换,或已有答案等待确认。\n请求:' + id.slice(0, 8)).catch(() => { });
197
+ await replyHint(api, chat, t('reply.notAppliedTitle'), t('reply.notAppliedBody', { id: id.slice(0, 8) })).catch(() => { });
197
198
  if (outcome === 'custom') {
198
199
  const value = await stateTransaction(access, state => state.requests[id]);
199
200
  if (!active(value))
200
201
  return true;
201
- const sent = await replyHint(api, chat, '✍️ 自定义输入', value.kind === 'permission' ? '请回复这条消息,填写拒绝说明。这段说明不会批准权限。' : '请回复这条消息,输入这个问题的完整答案。', callback.message.message_id, { force_reply: true, selective: true });
202
+ const sent = await replyHint(api, chat, t('reply.customInput'), value.kind === 'permission' ? t('reply.denyInstruction') : t('reply.answerInstruction'), callback.message.message_id, { force_reply: true, selective: true });
202
203
  if (Number.isSafeInteger(sent?.message_id))
203
204
  await stateTransaction(access, state => {
204
205
  state.entries[`tg:${chat}:${sent.message_id}`] = { session: value.session, epoch: value.epoch, request: id, question: value.index, updated: Date.now() };
@@ -211,7 +212,7 @@ export async function handleInteractionCallback(access, callback, chat, api) {
211
212
  export async function handleInteractionText(access, id, chat, message, api) {
212
213
  const text = message.text;
213
214
  if (!text.trim() || Array.from(text).length > 4000) {
214
- await replyHint(api, chat, '❌ 答案没有提交', '请输入不超过 4000 字符的答案;内容不会被截断。', message.message_id);
215
+ await replyHint(api, chat, t('reply.answerInvalidTitle'), t('reply.answerInvalidBody'), message.message_id);
215
216
  return;
216
217
  }
217
218
  const outcome = await stateTransaction(access, state => {
@@ -231,9 +232,9 @@ export async function handleInteractionText(access, id, chat, message, api) {
231
232
  question, answer: value.kind === 'question' ? text : 'deny', custom: true });
232
233
  return value.response ? 'done' : 'next';
233
234
  });
234
- await replyHint(api, chat, outcome === 'expired' ? '⚠️ 本次提交未生效' : '⏳ 答案已登记,等待确认', (outcome === 'done' ? '等待 Claude 确认,稍后会反馈最终结果;如果一直没有收到,请检查终端。'
235
- : outcome === 'next' ? '仍需完成其余问题,之后才会回传整份答案;目前还没有生效。'
236
- : '这次操作没有提交:对应问题已经结束、过期、切换,或已有答案等待确认。') + '\n请求:' + id.slice(0, 8));
235
+ await replyHint(api, chat, outcome === 'expired' ? t('reply.notAppliedTitle') : t('reply.pendingConfirmationTitle'), (outcome === 'done' ? t('reply.finalWaiting')
236
+ : outcome === 'next' ? t('reply.nextWaiting')
237
+ : t('reply.notSubmitted')) + t('reply.requestLine', { id: id.slice(0, 8) }));
237
238
  if (outcome === 'next')
238
239
  await showInteraction(access, id, chat, api);
239
240
  }
@@ -263,7 +264,7 @@ export async function waitInteraction(access, value, fresh, options = {}) {
263
264
  await stateTransaction(access, state => {
264
265
  const request = state.requests[value.id];
265
266
  if (request && !request.result)
266
- finishInteraction(request, { kind: request.claimed ? 'unknown' : 'not-applied', text: options.signal?.aborted ? '交互等待已取消,远程答案是否生效还没有确认。' : '远程交互等待超时,尚未确认生效。你仍可在本机继续处理。' });
267
+ finishInteraction(request, { kind: request.claimed ? 'unknown' : 'not-applied', reason: options.signal?.aborted ? 'failed' : 'expired', text: options.signal?.aborted ? t('reply.interactionCancelled') : t('reply.expiredUnsentAnswer') });
267
268
  });
268
269
  return null;
269
270
  }
@@ -1,4 +1,5 @@
1
1
  import { digest, stateTransaction, targetEpoch } from './reply-state.js';
2
+ import { t } from './i18n.js';
2
3
  import { replyHint } from './reply-telegram.js';
3
4
  // PermissionRequest 没有 tool_use_id:只接受唯一且尚未结束的同输入调用,歧义时保留本机处理。
4
5
  function canonical(value) {
@@ -27,6 +28,20 @@ export function finishInteraction(value, result) {
27
28
  delete receipt.question;
28
29
  }
29
30
  }
31
+ function endLabel(result) {
32
+ if (result.kind === 'unknown')
33
+ return t('reply.buttonUnknown');
34
+ if (result.kind === 'applied')
35
+ return t('reply.buttonApplied');
36
+ switch (result.reason) {
37
+ case 'expired': return t('reply.buttonExpired');
38
+ case 'session':
39
+ case 'switched': return t('reply.buttonInvalid');
40
+ case 'closed': return t('reply.buttonClosed');
41
+ case 'processed': return t('reply.buttonProcessed');
42
+ default: return t('reply.buttonEnded');
43
+ }
44
+ }
30
45
  export function endSessionInteractions(state, session, reason) {
31
46
  for (const call of Object.values(state.calls))
32
47
  if (call.session === session)
@@ -34,8 +49,8 @@ export function endSessionInteractions(state, session, reason) {
34
49
  for (const value of Object.values(state.requests))
35
50
  if (value.session === session && !value.result)
36
51
  finishInteraction(value, {
37
- kind: value.claimed ? 'unknown' : 'not-applied',
38
- text: value.claimed ? reason + ';还没有收到本次答案的生效确认,请检查终端。' : reason + ';这次远程答案没有回传。',
52
+ kind: value.claimed ? 'unknown' : 'not-applied', reason: 'session',
53
+ text: reason + (value.claimed ? t('reply.sessionUnknownSuffix') : t('reply.sessionUnsentSuffix')),
39
54
  });
40
55
  }
41
56
  export async function registerToolCall(access, target, data) {
@@ -92,24 +107,24 @@ export async function completeToolCall(access, data) {
92
107
  const q = value.questions.find(q => q.question === receipt.question);
93
108
  const exists = valid && answered && receipt.question !== undefined && Object.hasOwn(answers, receipt.question)
94
109
  && !output.annotations?.[receipt.question]?.unanswered;
95
- receipt.result = !value.claimed ? { kind: 'not-applied', text: '本机或其他端已经先处理了,这次 Telegram 答案没有提交。' }
96
- : !valid ? { kind: 'unknown', text: 'Claude 已经结束了这个问题,但没有返回可核实的结果。请检查终端。' }
110
+ receipt.result = !value.claimed ? { kind: 'not-applied', text: t('reply.earlierHandled') }
111
+ : !valid ? { kind: 'unknown', text: t('reply.noVerifiableResult') }
97
112
  : exists && sameAnswer(answers[receipt.question], receipt.answer, q?.multiSelect === true && receipt.custom !== true && q.options.every(o => !o.label.includes(',')))
98
- ? { kind: 'applied', text: 'Claude 已确认采用了与你提交内容一致的答案。如果本机也提交了相同内容,这次结果无法说明是哪一方生效。' }
99
- : { kind: 'not-applied', text: 'Claude 已经结束问题,但没有采用你提交的答案;本机或其他端可能已经先处理。' };
113
+ ? { kind: 'applied', text: t('reply.answerApplied') }
114
+ : { kind: 'not-applied', text: t('reply.answerNotApplied') };
100
115
  }
101
- finishInteraction(value, { kind: 'not-applied', text: '问题已处理,旧按钮已失效。' });
116
+ finishInteraction(value, { kind: 'not-applied', reason: 'processed', text: t('reply.questionProcessed') });
102
117
  }
103
118
  else if (data.hook_event_name === 'PostToolUse' && value.kind === 'permission') {
104
119
  const allow = value.claimed && value.receipts?.some(r => r.answer === 'allow');
105
120
  finishInteraction(value, allow
106
- ? { kind: 'applied', text: '工具已获准并执行完成。如果本机同时也批准了,完成事件本身无法说明是哪一方放行。' }
107
- : { kind: 'not-applied', text: '工具已经获准执行,这次远程决定没有阻止执行。' });
121
+ ? { kind: 'applied', reason: 'processed', text: t('reply.permissionApplied') }
122
+ : { kind: 'not-applied', reason: 'processed', text: t('reply.permissionNotApplied') });
108
123
  }
109
124
  else
110
- finishInteraction(value, { kind: value.claimed ? 'unknown' : 'not-applied', text: value.claimed
111
- ? '工具执行失败、被拒绝或取消。这个事件无法说明远程决定是否生效,请检查终端。'
112
- : '请求失败、被拒绝或取消,这次远程决定没有回传。' });
125
+ finishInteraction(value, { kind: value.claimed ? 'unknown' : 'not-applied', reason: 'failed', text: value.claimed
126
+ ? t('reply.toolFailedUnknown')
127
+ : t('reply.toolFailed') });
113
128
  });
114
129
  }
115
130
  export async function drainInteractionFeedback(access, api, fresh, log = () => { }) {
@@ -118,13 +133,21 @@ export async function drainInteractionFeedback(access, api, fresh, log = () => {
118
133
  return;
119
134
  const work = await stateTransaction(access, state => {
120
135
  for (const value of Object.values(state.requests)) {
136
+ if (value.result)
137
+ continue;
121
138
  const target = state.sessions[value.session];
122
- if (!value.result && (!target || targetEpoch(target) !== value.epoch || value.expires <= Date.now()))
123
- finishInteraction(value, {
124
- kind: value.claimed ? 'unknown' : 'not-applied', text: value.claimed
125
- ? '等待生效确认超时,或会话已经切换。结果暂时未知,请检查终端。'
126
- : '交互已过期或会话已结束;远程答案没有回传,你仍可在本机界面继续处理。',
127
- });
139
+ const ended = !target || targetEpoch(target) !== value.epoch;
140
+ if (!ended && value.expires > Date.now())
141
+ continue;
142
+ finishInteraction(value, {
143
+ kind: value.claimed ? 'unknown' : 'not-applied',
144
+ reason: ended ? 'session' : 'expired',
145
+ text: value.claimed
146
+ ? t('reply.waitTimeoutUnknown')
147
+ : ended
148
+ ? t('reply.sessionEndedUnsent')
149
+ : t('reply.expiredUnanswered'),
150
+ });
128
151
  }
129
152
  return Object.values(state.requests).filter(value => value.result || value.receipts?.some(r => r.result));
130
153
  });
@@ -146,12 +169,12 @@ export async function drainInteractionFeedback(access, api, fresh, log = () => {
146
169
  continue;
147
170
  let delivery = 'sent';
148
171
  try {
149
- const title = receipt.result.kind === 'applied' ? '✅ 答案内容已生效' : receipt.result.kind === 'not-applied' ? '⚠️ 本次提交未生效' : '⚠️ 提交结果暂时无法确认';
150
- await replyHint(api, receipt.chat, title, receipt.result.text + '\n请求:' + value.id.slice(0, 8));
172
+ const title = receipt.result.kind === 'applied' ? t('reply.appliedTitle') : receipt.result.kind === 'not-applied' ? t('reply.notAppliedTitle') : t('reply.resultUnknownTitle');
173
+ await replyHint(api, receipt.chat, title, receipt.result.text + t('reply.requestLabel', { id: value.id.slice(0, 8) }));
151
174
  }
152
175
  catch {
153
176
  delivery = 'unknown';
154
- log('Telegram 结果反馈没有发送成功。不会自动重发,已经采用的答案不受影响。');
177
+ log(t('reply.feedbackFailedLog'));
155
178
  }
156
179
  await stateTransaction(access, state => {
157
180
  const actual = state.requests[value.id]?.receipts?.find(r => r.id === receipt.id);
@@ -173,10 +196,10 @@ export async function drainInteractionFeedback(access, api, fresh, log = () => {
173
196
  if (actual)
174
197
  actual.cleared = true;
175
198
  });
176
- const label = value.result.kind === 'unknown' ? '已关闭 · 结果待核实' : value.result.text.includes('过期') ? '已过期 · 不再接受答案' : '已处理 · 不再接受答案';
199
+ const label = endLabel(value.result);
177
200
  await api('editMessageReplyMarkup', { chat_id: message.chat, message_id: message.message,
178
201
  reply_markup: { inline_keyboard: [[{ text: label, callback_data: `dn:q:${value.id}:0:l` }]] } })
179
- .catch(() => log('Telegram 旧按钮没有移除成功。服务端仍会拒绝这个请求。'));
202
+ .catch(() => log(t('reply.clearButtonsFailedLog')));
180
203
  }
181
204
  }
182
205
  }
@@ -3,12 +3,13 @@ import { readdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { ConfigError } from './config.js';
6
+ import { t } from './i18n.js';
6
7
  import { claudeSettingsPath } from './claude-code.js';
7
8
  export const runCommand = (command, args, input = '') => new Promise((resolve, reject) => {
8
9
  const child = spawn(command, args, { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
9
10
  let stdout = '', overflow = false;
10
- const timer = setTimeout(() => { child.kill(); reject(new ConfigError('系统会话操作超时。输入可能已经部分送达,为避免重复提交,不会自动重试。')); }, 15000);
11
- const fail = () => { clearTimeout(timer); reject(new ConfigError('系统会话操作失败。为了避免泄露命令或输入,这里不显示底层信息。')); };
11
+ const timer = setTimeout(() => { child.kill(); reject(new ConfigError(t('reply.platformTimeout'))); }, 15000);
12
+ const fail = () => { clearTimeout(timer); reject(new ConfigError(t('reply.platformFailed'))); };
12
13
  child.on('error', fail);
13
14
  child.stdin.on('error', () => { });
14
15
  child.stdout.on('data', data => { if (stdout.length + data.length > 1024 * 1024) {
@@ -28,12 +29,12 @@ function powershell(script, input, runner) {
28
29
  const READ_PROCESS = `$ErrorActionPreference='Stop'; [Console]::InputEncoding=[Text.UTF8Encoding]::new($false); $x=[Console]::In.ReadToEnd()|ConvertFrom-Json; $p=Get-Process -Id $x.pid; if($p.ProcessName -ne 'claude'){exit 3}; @{birth=$p.StartTime.ToUniversalTime().Ticks.ToString()}|ConvertTo-Json -Compress`;
29
30
  export function sanitizeReply(text) {
30
31
  if (typeof text !== 'string' || !text.trim())
31
- throw new ConfigError('回复内容不能为空。');
32
+ throw new ConfigError(t('reply.emptyReply'));
32
33
  if (Array.from(text).length > 4000)
33
- throw new ConfigError('回复超过 4000 字符,没有提交。内容不会被截断,也不会拆成多次发送。');
34
+ throw new ConfigError(t('reply.replyTooLong'));
34
35
  const value = text.replace(/\r\n|[\r\n\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
35
36
  if (!value)
36
- throw new ConfigError('回复清理后没有可提交的内容。');
37
+ throw new ConfigError(t('reply.emptyAfterCleanup'));
37
38
  return value;
38
39
  }
39
40
  export async function processIdentity(pid, platform = process.platform, runner = runCommand) {
@@ -166,15 +167,15 @@ if([DevNotifyConsole]::Send([uint32]$x.pid,[string]$x.text)){exit 0}else{exit 4}
166
167
  export async function injectReply(target, text, options = {}) {
167
168
  const safe = sanitizeReply(text), runner = options.runner ?? runCommand, platform = options.platform ?? process.platform;
168
169
  if (!await validateTarget(target, options))
169
- throw new ConfigError('会话已经关闭、切换,或无法核验身份。请从最新通知回复。');
170
+ throw new ConfigError(t('reply.targetInvalid'));
170
171
  if (platform === 'win32') {
171
172
  const result = await powershell(WINDOWS_INJECT, { pid: target.pid, birth: target.birth, text: safe }, runner);
172
173
  if (result.code !== 0)
173
- throw new ConfigError('Windows 控制台注入失败,可能只写入了一部分。不会自动重试,请检查终端。');
174
+ throw new ConfigError(t('reply.windowsPartial'));
174
175
  return;
175
176
  }
176
177
  if (platform !== 'darwin')
177
- throw new ConfigError('远程回复仅支持 Windows 和 macOS。');
178
+ throw new ConfigError(t('reply.unsupportedPlatform'));
178
179
  // 先确定 tmux pane,字面文本通过 stdin 缓冲区传入,绝不作为 shell 或按键名称执行。
179
180
  let listing = null;
180
181
  try {
@@ -186,14 +187,14 @@ export async function injectReply(target, text, options = {}) {
186
187
  const name = 'dev-notify-' + target.pid + '-' + Date.now();
187
188
  const loaded = await runner('tmux', ['load-buffer', '-b', name, '-'], safe);
188
189
  if (loaded.code !== 0)
189
- throw new ConfigError('tmux 输入缓冲区创建失败,这次回复没有提交。');
190
+ throw new ConfigError(t('reply.tmuxBufferFailed'));
190
191
  try {
191
192
  if (!await validateTarget(target, options))
192
- throw new ConfigError('会话已经变化,这次回复没有提交。');
193
+ throw new ConfigError(t('reply.tmuxChanged'));
193
194
  if ((await runner('tmux', ['paste-buffer', '-d', '-b', name, '-t', pane])).code !== 0)
194
- throw new ConfigError('tmux 粘贴失败,可能只送达了一部分。不会自动重试。');
195
+ throw new ConfigError(t('reply.tmuxPasteFailed'));
195
196
  if ((await runner('tmux', ['send-keys', '-t', pane, 'Enter'])).code !== 0)
196
- throw new ConfigError('tmux 提交失败,文本可能已经输入。不会自动重试。');
197
+ throw new ConfigError(t('reply.tmuxSubmitFailed'));
197
198
  }
198
199
  finally {
199
200
  await runner('tmux', ['delete-buffer', '-b', name]).catch(() => { });
@@ -205,5 +206,5 @@ export async function injectReply(target, text, options = {}) {
205
206
  const script = `tell application "Ghostty"\nrepeat with w in windows\nrepeat with t in terminals of w\nif tty of t is "${escape(target.tty)}" then\nperform action "${escape(action)}" on t\nreturn "ok"\nend if\nend repeat\nend repeat\nreturn "notfound"\nend tell`;
206
207
  const result = await runner('/usr/bin/osascript', ['-'], script);
207
208
  if (result.code !== 0 || result.stdout.trim() !== 'ok')
208
- throw new ConfigError('Ghostty 没有找到目标窗口,或注入失败。不会改投其他窗口,也不会重复输入。');
209
+ throw new ConfigError(t('reply.ghosttyFailed'));
209
210
  }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
6
+ import { joinList, t } from './i18n.js';
6
7
  import { replyHooksReady } from './claude-code.js';
7
8
  export const TTL = 24 * 60 * 60 * 1000;
8
9
  export const digest = (text) => createHash('sha256').update(text).digest('hex');
@@ -20,7 +21,7 @@ export async function readJson(file) {
20
21
  catch (error) {
21
22
  if (error.code === 'ENOENT')
22
23
  return null;
23
- throw new ConfigError('无法读取回复配置或状态。为了不覆盖现有数据,远程输入没有启用。');
24
+ throw new ConfigError(t('reply.readFailed'));
24
25
  }
25
26
  }
26
27
  export async function atomicJson(file, value) {
@@ -52,9 +53,9 @@ export async function locked(file, work) {
52
53
  }
53
54
  catch (error) {
54
55
  if (error.code !== 'EEXIST')
55
- throw new ConfigError('无法获取回复状态写入锁。');
56
+ throw new ConfigError(t('reply.lockUnavailable'));
56
57
  if (attempt === 99)
57
- throw new ConfigError('回复状态锁正忙。请确认相关进程已经退出,再处理残留锁。');
58
+ throw new ConfigError(t('reply.lockBusy'));
58
59
  await delay(40);
59
60
  }
60
61
  }
@@ -73,16 +74,16 @@ export async function accessReplies(file, env = process.env) {
73
74
  if (policy.version !== 1 || policy.enabled !== true || typeof policy.bot !== 'string' || !/^[a-f0-9]{64}$/.test(policy.bot)
74
75
  || typeof policy.generation !== 'string' || !/^[a-f0-9]{32}$/.test(policy.generation)
75
76
  || !Array.isArray(policy.users) || !policy.users.length || policy.users.some((id) => typeof id !== 'string' || !/^[1-9][0-9]*$/.test(id))) {
76
- throw new ConfigError('回复授权配置无效,远程输入已停用。');
77
+ throw new ConfigError(t('reply.invalidPolicy'));
77
78
  }
78
79
  if (!await replyHooksReady(file, { env }))
79
- throw new ConfigError('回复所需的 hooks 缺失、时序不安全或已禁用。请重新运行 integration claude-code install,并在 /hooks 中确认加载。');
80
+ throw new ConfigError(t('reply.hooksMissing'));
80
81
  const config = await loadConfig(file, true, env);
81
82
  if (!ready(config, 'telegram') || botKey(config) !== policy.bot)
82
- throw new ConfigError('Telegram Bot 已变更或不可用,请重新运行 reply setup 授权。');
83
+ throw new ConfigError(t('reply.botChanged'));
83
84
  const users = policy.users.filter((id) => config.telegram.chatIds.includes(id));
84
85
  if (!users.length)
85
- throw new ConfigError('授权用户不在当前 Telegram 接收名单中,远程输入已停用。');
86
+ throw new ConfigError(t('reply.usersRevoked'));
86
87
  const effective = { ...policy, users };
87
88
  return { file, config, policy: effective, identity: digest(JSON.stringify([policy.bot, policy.generation, users, policy.permissions === true])) };
88
89
  }
@@ -92,7 +93,7 @@ export async function stateTransaction(access, work) {
92
93
  const policy = await readJson(policyPath(access.file));
93
94
  if (!policy?.enabled || policy.bot !== access.policy.bot || policy.generation !== access.policy.generation
94
95
  || (policy.permissions === true) !== (access.policy.permissions === true))
95
- throw new ConfigError('回复授权已撤销或发生变化,没有写入旧状态。');
96
+ throw new ConfigError(t('reply.authorizationChanged'));
96
97
  let state = await readJson(path.join(directory, 'state.json'));
97
98
  if (!state || state.identity !== access.identity)
98
99
  state = { version: 1, identity: access.identity, offset: 0, sessions: {}, entries: {}, pending: {}, requests: {}, calls: {} };
@@ -101,7 +102,7 @@ export async function stateTransaction(access, work) {
101
102
  state.calls ??= {};
102
103
  if (state.version !== 1 || !Number.isSafeInteger(state.offset) || state.offset < 0
103
104
  || ![state.sessions, state.entries, state.pending, state.requests, state.calls].every(record))
104
- throw new ConfigError('回复状态结构无效,远程输入已停用。');
105
+ throw new ConfigError(t('reply.invalidState'));
105
106
  const now = Date.now();
106
107
  for (const section of [state.sessions, state.entries, state.pending, state.requests, state.calls])
107
108
  for (const [key, value] of Object.entries(section)) {
@@ -136,54 +137,54 @@ export async function stateTransaction(access, work) {
136
137
  }
137
138
  export async function configureReplies(file, prompts, env = process.env) {
138
139
  if (!await replyHooksReady(file, { env }))
139
- throw new ConfigError('请先运行 integration claude-code install,安装绑定此通知配置的完整 hooks,再配置回复。');
140
+ throw new ConfigError(t('reply.setupHooksFirst'));
140
141
  const config = await loadConfig(file, true, env);
141
142
  if (!ready(config, 'telegram'))
142
- throw new ConfigError('回复 Claude 需要先配置 Telegram Bot 和私聊接收人。');
143
+ throw new ConfigError(t('reply.setupTelegramFirst'));
143
144
  const available = config.telegram.chatIds.filter(id => /^[1-9][0-9]*$/.test(id));
144
145
  if (!available.length)
145
- throw new ConfigError('远程回复仅支持 Telegram 私聊用户,不支持群聊。');
146
+ throw new ConfigError(t('reply.setupPrivateOnly'));
146
147
  const before = await readJson(policyPath(file));
147
- prompts.out('远程回复等同于在本机 Claude Code 终端输入并提交内容。它可以提交 Claude 命令,但不会自动批准工具权限。请只授权给可信用户。');
148
- prompts.out('飞书按钮会跳转到 Telegram,不直接接收飞书消息。这个功能需要独占 Bot 的 getUpdates,请不要与旧版桥接或其他机器共用同一个 Bot。');
149
- prompts.out('Windows 使用系统 PowerShell / 控制台 API;macOS 使用 Ghostty 或 tmux,需要相应的系统权限。');
150
- prompts.out('目前不支持 VS Code 集成终端、WSL 和 SSH。目标不确定或会话已经切换时,会拒绝输入。');
151
- prompts.out('通知接收人不会自动获得输入权限。可选择的私聊 chat_id:' + available.join('、'));
152
- const text = await prompts.text('请输入要授权的私聊 chat_id(逗号分隔;留空取消):');
148
+ prompts.out(t('reply.setupNoticeInput'));
149
+ prompts.out(t('reply.setupNoticeBridge'));
150
+ prompts.out(t('reply.setupNoticePlatform'));
151
+ prompts.out(t('reply.setupNoticeUnsupported'));
152
+ prompts.out(t('reply.setupAvailable', { users: joinList(available) }));
153
+ const text = await prompts.text(t('reply.setupPrompt'));
153
154
  if (!text.trim())
154
155
  return false;
155
156
  const users = [...new Set(text.split(',').map(id => id.trim()))];
156
157
  if (users.some(id => !available.includes(id)))
157
- throw new ConfigError('只能授权当前通知名单中的私聊用户。');
158
- prompts.out('将授权以下用户远程输入:' + users.join('、'));
159
- prompts.out('结构化问题会展示真实选项,也支持自定义回答;普通回复会在本轮结束后输入。问题、选项和工具请求参数可能包含敏感信息,会发送到授权私聊和已配置的飞书。');
160
- const permissions = /^(y|yes)$/i.test(await prompts.text('是否允许这些用户逐次批准或拒绝工具权限?这不会修改永久规则。[y/N] '));
161
- if (!/^(y|yes)$/i.test(await prompts.text('确认启用远程回复并接受上述权限范围?[y/N] ')))
158
+ throw new ConfigError(t('reply.setupInvalidUser'));
159
+ prompts.out(t('reply.setupAuthorizing', { users: joinList(users) }));
160
+ prompts.out(t('reply.setupSensitive'));
161
+ const permissions = /^(y|yes)$/i.test(await prompts.text(t('reply.setupPermissionsPrompt')));
162
+ if (!/^(y|yes)$/i.test(await prompts.text(t('reply.setupConfirm'))))
162
163
  return false;
163
164
  await locked(policyPath(file) + '.lock', async () => {
164
165
  if (JSON.stringify(before) !== JSON.stringify(await readJson(policyPath(file))))
165
- throw new ConfigError('回复授权已被其他进程修改,请重试。');
166
+ throw new ConfigError(t('reply.setupChanged'));
166
167
  const effective = await loadConfig(file, true, env);
167
168
  if (botKey(effective) !== botKey(config) || users.some(id => !effective.telegram.chatIds.includes(id)))
168
- throw new ConfigError('通知配置在授权期间发生了变化,请重试。');
169
+ throw new ConfigError(t('reply.setupConfigChanged'));
169
170
  await atomicJson(policyPath(file), { version: 1, enabled: true, bot: botKey(config), users, permissions, generation: randomBytes(16).toString('hex') });
170
171
  });
171
- prompts.out('授权已保存。旧消息的回复映射不会保留;请从启用后的任务完成通知进入。');
172
+ prompts.out(t('reply.setupSaved'));
172
173
  return true;
173
174
  }
174
175
  export async function disableReplies(file, prompts) {
175
176
  const before = await readJson(policyPath(file));
176
177
  if (!before?.enabled) {
177
- prompts.out('远程回复尚未启用。');
178
+ prompts.out(t('reply.disableNotEnabled'));
178
179
  return true;
179
180
  }
180
- if (!/^(y|yes)$/i.test(await prompts.text('确认禁用远程回复?通知和凭据都会保留。[y/N] ')))
181
+ if (!/^(y|yes)$/i.test(await prompts.text(t('reply.disableConfirm'))))
181
182
  return false;
182
183
  await locked(policyPath(file) + '.lock', async () => {
183
184
  if (JSON.stringify(before) !== JSON.stringify(await readJson(policyPath(file))))
184
- throw new ConfigError('回复授权发生了变化,请重试。');
185
+ throw new ConfigError(t('reply.disableChanged'));
185
186
  await atomicJson(policyPath(file), { ...before, enabled: false });
186
187
  });
187
- prompts.out('远程回复已禁用。正在进行的操作无法撤回,桥接会在下次检查时退出。');
188
+ prompts.out(t('reply.disabled'));
188
189
  return true;
189
190
  }
@@ -1,14 +1,15 @@
1
1
  import { requestJson } from './channels.js';
2
2
  import { ConfigError } from './config.js';
3
+ import { t } from './i18n.js';
3
4
  export function telegramApi(token, request = requestJson) {
4
5
  return async (method, payload) => {
5
6
  if (!['sendMessage', 'editMessageReplyMarkup', 'answerCallbackQuery', 'getMe', 'getUpdates', 'getWebhookInfo'].includes(method))
6
- throw new ConfigError('不支持的 Telegram 桥接方法');
7
+ throw new ConfigError(t('reply.telegramMethod'));
7
8
  const response = await request(`https://api.telegram.org/bot${token}/${method}`, payload);
8
9
  if (response.ok !== true) {
9
10
  if (response.error_code === 409)
10
- throw new ConfigError('Telegram 轮询冲突;检查旧版桥接、其他机器或 webhook,桥接不会抢占或删除 webhook');
11
- throw new ConfigError('Telegram 桥接请求失败;不输出服务端正文或秘密');
11
+ throw new ConfigError(t('reply.telegramConflict'));
12
+ throw new ConfigError(t('reply.telegramRequestFailed'));
12
13
  }
13
14
  return response.result;
14
15
  };