@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.
- package/CHANGELOG.en.md +83 -0
- package/CHANGELOG.md +15 -0
- package/README.en.md +275 -0
- package/README.md +11 -4
- package/SECURITY.en.md +78 -0
- package/SECURITY.md +5 -3
- package/dist/channels.js +12 -11
- package/dist/claude-code.js +30 -30
- package/dist/claude-hook.js +23 -23
- package/dist/cli.js +106 -81
- package/dist/config.js +37 -20
- package/dist/credentials.js +14 -13
- package/dist/formatting.js +6 -4
- package/dist/i18n.js +960 -0
- package/dist/maintenance.js +17 -16
- package/dist/prompts.js +7 -6
- package/dist/reply-bridge.js +11 -10
- package/dist/reply-engine.js +20 -19
- package/dist/reply-hooks.js +21 -20
- package/dist/reply-interactions.js +36 -35
- package/dist/reply-outcomes.js +46 -23
- package/dist/reply-platform.js +14 -13
- package/dist/reply-state.js +31 -30
- package/dist/reply-telegram.js +4 -3
- package/dist/setup.js +69 -51
- package/dist/update.js +39 -31
- package/package.json +4 -1
|
@@ -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 =
|
|
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: '
|
|
60
|
-
buttons.push([{ text: '
|
|
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: '
|
|
67
|
-
buttons.push([{ text: '
|
|
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: '
|
|
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 '
|
|
75
|
-
+ '\n\n
|
|
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
|
|
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 ? '
|
|
80
|
-
+ '
|
|
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) =>
|
|
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 ? '
|
|
87
|
-
: '
|
|
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: '
|
|
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('
|
|
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' ? '
|
|
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, '
|
|
194
|
-
+ '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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' ? '
|
|
235
|
-
: outcome === 'next' ? '
|
|
236
|
-
: '
|
|
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
|
}
|
package/dist/reply-outcomes.js
CHANGED
|
@@ -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 ?
|
|
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: '
|
|
96
|
-
: !valid ? { kind: 'unknown', text: '
|
|
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: '
|
|
99
|
-
: { kind: 'not-applied', text: '
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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' ? '
|
|
150
|
-
await replyHint(api, receipt.chat, title, receipt.result.text + '
|
|
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('
|
|
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
|
|
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('
|
|
202
|
+
.catch(() => log(t('reply.clearButtonsFailedLog')));
|
|
180
203
|
}
|
|
181
204
|
}
|
|
182
205
|
}
|
package/dist/reply-platform.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
174
|
+
throw new ConfigError(t('reply.windowsPartial'));
|
|
174
175
|
return;
|
|
175
176
|
}
|
|
176
177
|
if (platform !== 'darwin')
|
|
177
|
-
throw new ConfigError('
|
|
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('
|
|
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('
|
|
195
|
+
throw new ConfigError(t('reply.tmuxPasteFailed'));
|
|
195
196
|
if ((await runner('tmux', ['send-keys', '-t', pane, 'Enter'])).code !== 0)
|
|
196
|
-
throw new ConfigError('
|
|
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('
|
|
209
|
+
throw new ConfigError(t('reply.ghosttyFailed'));
|
|
209
210
|
}
|
package/dist/reply-state.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
146
|
+
throw new ConfigError(t('reply.setupPrivateOnly'));
|
|
146
147
|
const before = await readJson(policyPath(file));
|
|
147
|
-
prompts.out('
|
|
148
|
-
prompts.out('
|
|
149
|
-
prompts.out('
|
|
150
|
-
prompts.out('
|
|
151
|
-
prompts.out('
|
|
152
|
-
const text = await prompts.text('
|
|
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('
|
|
159
|
-
prompts.out('
|
|
160
|
-
const permissions = /^(y|yes)$/i.test(await prompts.text('
|
|
161
|
-
if (!/^(y|yes)$/i.test(await prompts.text('
|
|
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('
|
|
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
|
}
|
package/dist/reply-telegram.js
CHANGED
|
@@ -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('
|
|
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('
|
|
11
|
-
throw new ConfigError('
|
|
11
|
+
throw new ConfigError(t('reply.telegramConflict'));
|
|
12
|
+
throw new ConfigError(t('reply.telegramRequestFailed'));
|
|
12
13
|
}
|
|
13
14
|
return response.result;
|
|
14
15
|
};
|