cliclaw 1.0.41 → 1.0.43

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/index.ts CHANGED
@@ -4,6 +4,7 @@ import { Storage } from './src/storage'
4
4
  import { registerCommands } from './src/handlers/commands'
5
5
  import { registerMessageHandler } from './src/handlers/messages'
6
6
  import { respondApproval } from './src/handlers/approvals'
7
+ import { handleSndCallback, handleUsoCallback } from './src/handlers/send'
7
8
 
8
9
  const config = loadConfig()
9
10
  const storage = new Storage(config.DATA_DIR)
@@ -36,10 +37,29 @@ bot.on('callback_query:data', async (ctx) => {
36
37
  const data = ctx.callbackQuery.data
37
38
  console.log(`[callback] recebido: ${data.slice(0, 60)}`)
38
39
 
39
- const colonIdx = data.indexOf(':')
40
- const prefix = data.slice(0, colonIdx)
41
- const approvalId = data.slice(colonIdx + 1)
42
- const entry = APPROVAL_PREFIXES[prefix]
40
+ const colonIdx = data.indexOf(':')
41
+ const prefix = data.slice(0, colonIdx)
42
+ const rest = data.slice(colonIdx + 1)
43
+
44
+ // ── uso:* menu callbacks ────────────────────────────────────────────────
45
+ if (prefix === 'uso') {
46
+ await handleUsoCallback(ctx, storage, config, rest).catch(e =>
47
+ console.error(`[callback] uso erro: ${e.message}`)
48
+ )
49
+ return
50
+ }
51
+
52
+ // ── snd:* send/files callbacks ──────────────────────────────────────────
53
+ if (prefix === 'snd') {
54
+ await handleSndCallback(ctx, storage, config, rest).catch(e =>
55
+ console.error(`[callback] snd erro: ${e.message}`)
56
+ )
57
+ return
58
+ }
59
+
60
+ // ── codex approval callbacks ────────────────────────────────────────────
61
+ const approvalId = rest
62
+ const entry = APPROVAL_PREFIXES[prefix]
43
63
  if (!entry) return
44
64
 
45
65
  console.log(`[callback] clicou ${prefix} id=${approvalId.slice(0, 8)}...`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliclaw",
3
- "version": "1.0.41",
3
+ "version": "1.0.43",
4
4
  "description": "Telegram bot bridging AI CLIs (Claude Code, Codex) to Forum Topics",
5
5
  "main": "index.ts",
6
6
  "scripts": {
@@ -34,7 +34,7 @@ interface ClaudeResult {
34
34
  usage?: TokenUsage
35
35
  }
36
36
 
37
- function spawnClaude(args: string[]): Promise<ClaudeResult> {
37
+ function spawnClaude(args: string[], stdinText?: string): Promise<ClaudeResult> {
38
38
  return new Promise((resolve, reject) => {
39
39
  let stdout = ''
40
40
  let stderr = ''
@@ -43,8 +43,11 @@ function spawnClaude(args: string[]): Promise<ClaudeResult> {
43
43
  env: buildEnv(),
44
44
  shell: isWin,
45
45
  windowsHide: true,
46
- stdio: ['ignore', 'pipe', 'pipe'], // explicit: ignore stdin so headless PM2 doesn't hang
46
+ stdio: [stdinText !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
47
47
  })
48
+ if (stdinText !== undefined) {
49
+ proc.stdin!.end(stdinText, 'utf8')
50
+ }
48
51
  proc.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
49
52
  proc.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
50
53
  proc.on('close', (code) => {
@@ -80,18 +83,18 @@ export async function askClaude(
80
83
  if (session.claudeSessionId) {
81
84
  return await spawnClaude([
82
85
  '--resume', session.claudeSessionId,
83
- '-p', userMessage,
86
+ '-p',
84
87
  '--output-format', 'json',
85
88
  '--dangerously-skip-permissions',
86
- ])
89
+ ], userMessage)
87
90
  } else {
88
91
  const newId = randomUUID()
89
92
  const result = await spawnClaude([
90
- '-p', userMessage,
93
+ '-p',
91
94
  '--output-format', 'json',
92
95
  '--session-id', newId,
93
96
  '--dangerously-skip-permissions',
94
- ])
97
+ ], userMessage)
95
98
  onNewSessionId?.(result.sessionId ?? newId)
96
99
  return result
97
100
  }
@@ -6,6 +6,7 @@ import { killCodexSession } from '../agents/codex'
6
6
  import { formatTelegramMarkdown, splitTelegramMessage, TELEGRAM_MARKDOWN_OPTS } from '../telegram'
7
7
  import { dockerHelpText, pm2HelpText, runDockerCommand, runPm2Command } from '../admin'
8
8
  import { getLang, t } from '../i18n'
9
+ import { getCacheDir, handleSendCommand } from './send'
9
10
 
10
11
  // ─── helpers ────────────────────────────────────────────────────────────────
11
12
 
@@ -252,6 +253,44 @@ export function registerCommands(bot: Bot<Context>, storage: Storage, config: Co
252
253
  await replyChunks(ctx, msg)
253
254
  })
254
255
 
256
+ // /uso
257
+ bot.command('uso', async (ctx) => {
258
+ const lang = getLang(ctx)
259
+ const chatId = String(ctx.chat!.id)
260
+ const threadId = ctx.message?.message_thread_id ?? 0
261
+ const session = threadId
262
+ ? (storage.getSessionByThreadId(chatId, threadId) || storage.getActiveSession(chatId))
263
+ : storage.getActiveSession(chatId)
264
+
265
+ const cacheDir = getCacheDir(config.DATA_DIR, chatId)
266
+ const emoji = session ? (session.model === 'claude' ? '🟣' : '🟢') : '🤖'
267
+ const name = session ? session.name : (lang === 'pt' ? 'Nenhuma sessão' : 'No session')
268
+ const title = `${emoji} <b>${name}</b>`
269
+
270
+ const keyboard = {
271
+ inline_keyboard: [
272
+ [
273
+ { text: lang === 'pt' ? '📊 Status' : '📊 Status', callback_data: 'uso:stat' },
274
+ { text: lang === 'pt' ? '🧹 Limpar' : '🧹 Clear', callback_data: 'uso:clr' },
275
+ ],
276
+ [
277
+ { text: lang === 'pt' ? '📁 Enviar arquivo' : '📁 Send file', callback_data: 'uso:snd' },
278
+ ],
279
+ ],
280
+ }
281
+
282
+ const opts: any = { parse_mode: 'HTML', reply_markup: keyboard }
283
+ if (threadId) opts.message_thread_id = threadId
284
+ await ctx.reply(title, opts)
285
+ })
286
+
287
+ // /send + /enviar
288
+ const sendHandler = async (ctx: Context) => {
289
+ await handleSendCommand(ctx, storage, config)
290
+ }
291
+ bot.command('send', sendHandler)
292
+ bot.command('enviar', sendHandler)
293
+
255
294
  // /docker
256
295
  bot.command('docker', async (ctx) => {
257
296
  if (!await guardAdmin(ctx, config)) return
@@ -0,0 +1,261 @@
1
+ import { existsSync, readdirSync, mkdirSync, statSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { InputFile } from 'grammy'
4
+ import type { Context } from 'grammy'
5
+ import type { Storage } from '../storage'
6
+ import type { Config } from '../config'
7
+ import { getLang } from '../i18n'
8
+ import { mdToTg, splitHtml } from '../utils/markdown'
9
+ import { askClaude } from '../agents/claude'
10
+ import { askCodex } from '../agents/codex'
11
+
12
+ // ─── cache helpers ────────────────────────────────────────────────────────────
13
+
14
+ export function getCacheDir(dataDir: string, chatId: string): string {
15
+ const dir = join(dataDir, 'send_cache', chatId)
16
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
17
+ return dir
18
+ }
19
+
20
+ export function listCacheFiles(dataDir: string, chatId: string): string[] {
21
+ const dir = getCacheDir(dataDir, chatId)
22
+ try {
23
+ return readdirSync(dir).filter(f => statSync(join(dir, f)).isFile()).sort()
24
+ } catch { return [] }
25
+ }
26
+
27
+ // ─── guide text ──────────────────────────────────────────────────────────────
28
+
29
+ function getSendGuide(cacheDir: string): string {
30
+ return (
31
+ '📁 **Guia de envio de arquivos / File Transfer Guide**\n\n' +
32
+ '**🇧🇷 Português:**\n' +
33
+ 'O usuário quer receber um arquivo via Telegram. Para enviar:\n' +
34
+ `1. Copie o arquivo para esta pasta: \`${cacheDir}\`\n` +
35
+ '2. Avise o usuário para abrir `/send` no bot e clicar em 📥.\n' +
36
+ `Exemplo: \`cp meu_arquivo.txt "${cacheDir}/"\`\n\n` +
37
+ '**🇺🇸 English:**\n' +
38
+ 'The user wants to receive a file via Telegram. To send it:\n' +
39
+ `1. Copy the file to this folder: \`${cacheDir}\`\n` +
40
+ '2. Tell the user to open `/send` in the bot and click 📥.\n' +
41
+ `Example: \`cp my_file.txt "${cacheDir}/"\``
42
+ )
43
+ }
44
+
45
+ // ─── menu builders ───────────────────────────────────────────────────────────
46
+
47
+ function escHtml(s: string) {
48
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
49
+ }
50
+
51
+ export function buildSendMenuHtml(cacheDir: string, files: string[], lang: 'pt' | 'en'): string {
52
+ const title = lang === 'pt' ? '📁 <b>Arquivos disponíveis</b>' : '📁 <b>Available Files</b>'
53
+ const pathLine = `${lang === 'pt' ? 'Pasta' : 'Folder'}: <code>${escHtml(cacheDir)}</code>`
54
+ const none = lang === 'pt'
55
+ ? '<i>Nenhum arquivo ainda — copie um para a pasta acima</i>'
56
+ : '<i>No files yet — copy one to the folder above</i>'
57
+ const list = files.length
58
+ ? files.map((f, i) => `${i + 1}. <code>${escHtml(f)}</code>`).join('\n')
59
+ : none
60
+ return `${title}\n${pathLine}\n\n${list}`
61
+ }
62
+
63
+ export function buildSendKeyboard(files: string[], lang: 'pt' | 'en') {
64
+ const rows: Array<Array<{ text: string; callback_data: string }>> = []
65
+
66
+ // File buttons (max 8, 2 per row)
67
+ const fbtns = files.slice(0, 8).map((f, i) => ({
68
+ text: `📥 ${f.slice(0, 18)}${f.length > 18 ? '…' : ''}`,
69
+ callback_data: `snd:f:${i}`,
70
+ }))
71
+ for (let i = 0; i < fbtns.length; i += 2) rows.push(fbtns.slice(i, i + 2))
72
+
73
+ // Action row
74
+ rows.push([
75
+ { text: lang === 'pt' ? '📬 Pedir ao agente' : '📬 Ask agent', callback_data: 'snd:guide' },
76
+ { text: lang === 'pt' ? '🔄 Atualizar' : '🔄 Refresh', callback_data: 'snd:ref' },
77
+ ])
78
+
79
+ return { inline_keyboard: rows }
80
+ }
81
+
82
+ // ─── /send command handler ───────────────────────────────────────────────────
83
+
84
+ export async function handleSendCommand(ctx: Context, storage: Storage, config: Config) {
85
+ const lang = getLang(ctx)
86
+ const chatId = String(ctx.chat!.id)
87
+ const threadId = ctx.message?.message_thread_id ?? 0
88
+ const cacheDir = getCacheDir(config.DATA_DIR, chatId)
89
+ const files = listCacheFiles(config.DATA_DIR, chatId)
90
+
91
+ const opts: any = {
92
+ parse_mode: 'HTML',
93
+ reply_markup: buildSendKeyboard(files, lang),
94
+ }
95
+ if (threadId) opts.message_thread_id = threadId
96
+
97
+ await ctx.reply(buildSendMenuHtml(cacheDir, files, lang), opts)
98
+ }
99
+
100
+ // ─── snd:* callback handler ──────────────────────────────────────────────────
101
+
102
+ export async function handleSndCallback(
103
+ ctx: Context,
104
+ storage: Storage,
105
+ config: Config,
106
+ action: string,
107
+ ) {
108
+ const lang = getLang(ctx)
109
+ const chatId = String(ctx.chat!.id)
110
+ const threadId = (ctx.callbackQuery as any)?.message?.message_thread_id ?? 0
111
+ const cacheDir = getCacheDir(config.DATA_DIR, chatId)
112
+ const replyOpts: any = threadId ? { message_thread_id: threadId } : {}
113
+
114
+ await ctx.answerCallbackQuery().catch(() => {})
115
+
116
+ // ── refresh ──────────────────────────────────────────────────────────────
117
+ if (action === 'ref') {
118
+ const files = listCacheFiles(config.DATA_DIR, chatId)
119
+ try {
120
+ await ctx.editMessageText(buildSendMenuHtml(cacheDir, files, lang), {
121
+ parse_mode: 'HTML',
122
+ reply_markup: buildSendKeyboard(files, lang),
123
+ })
124
+ } catch {}
125
+ return
126
+ }
127
+
128
+ // ── inject guide into agent ──────────────────────────────────────────────
129
+ if (action === 'guide') {
130
+ const session = threadId
131
+ ? (storage.getSessionByThreadId(chatId, threadId) || storage.getActiveSession(chatId))
132
+ : storage.getActiveSession(chatId)
133
+
134
+ if (!session) {
135
+ await ctx.reply(
136
+ lang === 'pt' ? '⚠️ Nenhuma sessão ativa.' : '⚠️ No active session.',
137
+ replyOpts
138
+ )
139
+ return
140
+ }
141
+
142
+ const guide = getSendGuide(cacheDir)
143
+ const emoji = session.model === 'claude' ? '🟣' : '🟢'
144
+ const status = await ctx.reply(
145
+ `${emoji} <i>${lang === 'pt' ? 'Enviando guia ao agente...' : 'Sending guide to agent...'}</i>`,
146
+ { ...replyOpts, parse_mode: 'HTML' }
147
+ )
148
+
149
+ try {
150
+ let result: { text: string }
151
+ if (session.model === 'claude') {
152
+ result = await askClaude(session, guide,
153
+ (id) => storage.setClaudeSessionId(chatId, session.id, id))
154
+ } else {
155
+ result = await askCodex(session, guide,
156
+ (id) => storage.setCodexThreadId(chatId, session.id, id))
157
+ }
158
+ try { await ctx.api.deleteMessage(chatId, status.message_id) } catch {}
159
+ const html = mdToTg(result.text)
160
+ for (const chunk of splitHtml(html)) {
161
+ if (chunk) await ctx.api.sendMessage(chatId, `${emoji} ${chunk}`, { ...replyOpts, parse_mode: 'HTML' })
162
+ }
163
+ } catch (e: any) {
164
+ try { await ctx.api.deleteMessage(chatId, status.message_id) } catch {}
165
+ await ctx.reply(`❌ ${e.message}`, replyOpts)
166
+ }
167
+ return
168
+ }
169
+
170
+ // ── download file ─────────────────────────────────────────────────────────
171
+ if (action.startsWith('f:')) {
172
+ const idx = parseInt(action.slice(2), 10)
173
+ const files = listCacheFiles(config.DATA_DIR, chatId)
174
+ const fname = files[idx]
175
+
176
+ if (!fname) {
177
+ await ctx.reply(
178
+ lang === 'pt' ? '⚠️ Arquivo não encontrado.' : '⚠️ File not found.',
179
+ replyOpts
180
+ )
181
+ return
182
+ }
183
+
184
+ try {
185
+ await ctx.replyWithDocument(new InputFile(join(cacheDir, fname), fname), replyOpts)
186
+ } catch (e: any) {
187
+ await ctx.reply(`❌ ${e.message}`, replyOpts)
188
+ }
189
+ }
190
+ }
191
+
192
+ // ─── uso:* callback handler ──────────────────────────────────────────────────
193
+
194
+ export async function handleUsoCallback(
195
+ ctx: Context,
196
+ storage: Storage,
197
+ config: Config,
198
+ action: string,
199
+ ) {
200
+ const lang = getLang(ctx)
201
+ const chatId = String(ctx.chat!.id)
202
+ const threadId = (ctx.callbackQuery as any)?.message?.message_thread_id ?? 0
203
+ const replyOpts: any = threadId ? { message_thread_id: threadId } : {}
204
+
205
+ await ctx.answerCallbackQuery().catch(() => {})
206
+
207
+ const getSession = () => threadId
208
+ ? (storage.getSessionByThreadId(chatId, threadId) || storage.getActiveSession(chatId))
209
+ : storage.getActiveSession(chatId)
210
+
211
+ // ── status ───────────────────────────────────────────────────────────────
212
+ if (action === 'stat') {
213
+ const session = getSession()
214
+ if (!session) {
215
+ await ctx.reply(lang === 'pt' ? '⚠️ Nenhuma sessão ativa.' : '⚠️ No active session.', replyOpts)
216
+ return
217
+ }
218
+ const emoji = session.model === 'claude' ? '🟣' : '🟢'
219
+ const msgs = lang === 'pt' ? `${session.history.length} mensagens` : `${session.history.length} messages`
220
+ const since = lang === 'pt' ? 'desde' : 'since'
221
+ await ctx.reply(
222
+ `${emoji} <b>${escHtml(session.name)}</b>\n` +
223
+ `💬 ${msgs}\n` +
224
+ `🕐 ${since} ${new Date(session.createdAt).toLocaleString()}`,
225
+ { ...replyOpts, parse_mode: 'HTML' }
226
+ )
227
+ return
228
+ }
229
+
230
+ // ── clear ────────────────────────────────────────────────────────────────
231
+ if (action === 'clr') {
232
+ const { killSession } = await import('../agents/claude')
233
+ const { killCodexSession } = await import('../agents/codex')
234
+ const session = getSession()
235
+ if (!session) {
236
+ await ctx.reply(lang === 'pt' ? '⚠️ Nenhuma sessão ativa.' : '⚠️ No active session.', replyOpts)
237
+ return
238
+ }
239
+ killSession(session.id)
240
+ killCodexSession(session.id)
241
+ storage.clearSession(chatId, session.id)
242
+ const emoji = session.model === 'claude' ? '🟣' : '🟢'
243
+ await ctx.reply(
244
+ `🧹 <b>${escHtml(session.name)}</b> ${lang === 'pt' ? 'reiniciada!' : 'cleared!'}`,
245
+ { ...replyOpts, parse_mode: 'HTML' }
246
+ )
247
+ return
248
+ }
249
+
250
+ // ── send files (show send menu) ───────────────────────────────────────────
251
+ if (action === 'snd') {
252
+ const cacheDir = getCacheDir(config.DATA_DIR, chatId)
253
+ const files = listCacheFiles(config.DATA_DIR, chatId)
254
+ await ctx.reply(buildSendMenuHtml(cacheDir, files, lang), {
255
+ ...replyOpts,
256
+ parse_mode: 'HTML',
257
+ reply_markup: buildSendKeyboard(files, lang),
258
+ })
259
+ return
260
+ }
261
+ }
package/src/i18n.ts CHANGED
@@ -21,6 +21,8 @@ const strings = {
21
21
  '`/sessoes` ou `/sessions` — listar sessões\n' +
22
22
  '`/limpar` ou `/clear` — reiniciar sessão atual\n' +
23
23
  '`/status` — info da sessão ativa\n' +
24
+ '`/uso` — menu rápido (status, limpar, arquivos)\n' +
25
+ '`/send` ou `/enviar` — enviar arquivos via Telegram\n' +
24
26
  '`/id` — ID deste chat\n' +
25
27
  '`/ajuda` ou `/help` — exibir ajuda',
26
28
  en:
@@ -29,6 +31,8 @@ const strings = {
29
31
  '`/sessions` or `/sessoes` — list sessions\n' +
30
32
  '`/clear` or `/limpar` — reset current session\n' +
31
33
  '`/status` — active session info\n' +
34
+ '`/uso` — quick menu (status, clear, files)\n' +
35
+ '`/send` or `/enviar` — send files via Telegram\n' +
32
36
  '`/id` — this chat ID\n' +
33
37
  '`/help` or `/ajuda` — show help',
34
38
  },