ticlawk 0.1.15-dev.6 → 0.1.16-dev.1

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,359 +0,0 @@
1
- import { basename, extname } from 'node:path';
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { randomUUID } from 'node:crypto';
4
- import { join } from 'node:path';
5
- import { JsonFileStore } from '../../core/store/json-file-store.mjs';
6
- import { parseOptionArgs } from '../../core/argv.mjs';
7
- import { AF_ADAPTER_KEY, AF_HOME, loadPersistentConfig, persistConfig } from '../../core/config.mjs';
8
-
9
- const TELEGRAM_API = 'https://api.telegram.org';
10
- const TEXT_LIMIT = 4000;
11
- const UNBOUND_REPLY_COOLDOWN_MS = 30000;
12
- const UNCLAIMED_TARGET_KEY = 'telegram:unclaimed';
13
- const DEFAULT_BINDING_ID = 'telegram:default';
14
-
15
- function splitText(text) {
16
- const normalized = String(text || '');
17
- if (normalized.length <= TEXT_LIMIT) return [normalized];
18
- const chunks = [];
19
- for (let index = 0; index < normalized.length; index += TEXT_LIMIT) {
20
- chunks.push(normalized.slice(index, index + TEXT_LIMIT));
21
- }
22
- return chunks;
23
- }
24
-
25
- export function createTelegramAdapter(ctx) {
26
- const stateStore = new JsonFileStore(join(AF_HOME, 'telegram-state.json'), {
27
- updateOffset: 0,
28
- botId: null,
29
- username: null,
30
- });
31
- const typingSentAt = new Map();
32
- const unboundReplySentAt = new Map();
33
- let polling = false;
34
- let stopped = false;
35
-
36
- function getToken() {
37
- const config = loadPersistentConfig();
38
- return String(process.env.TELEGRAM_BOT_TOKEN || config.TELEGRAM_BOT_TOKEN || '').trim();
39
- }
40
-
41
- async function apiCall(method, body, opts = {}) {
42
- const token = getToken();
43
- if (!token) throw new Error('TELEGRAM_BOT_TOKEN is not configured');
44
- const url = `${TELEGRAM_API}/bot${token}/${method}`;
45
- const response = await fetch(url, {
46
- method: 'POST',
47
- body: opts.formData ? body : JSON.stringify(body || {}),
48
- headers: opts.formData ? undefined : { 'Content-Type': 'application/json' },
49
- });
50
- const json = await response.json().catch(() => ({}));
51
- if (!response.ok || json.ok === false) {
52
- throw new Error(json.description || `telegram ${method} failed`);
53
- }
54
- return json.result;
55
- }
56
-
57
- async function sendMessage(chatId, text, replyToMessageId = null) {
58
- const chunks = splitText(text);
59
- for (const chunk of chunks) {
60
- await apiCall('sendMessage', {
61
- chat_id: chatId,
62
- text: chunk,
63
- ...(replyToMessageId ? { reply_to_message_id: Number(replyToMessageId) } : {}),
64
- });
65
- }
66
- }
67
-
68
- async function sendMedia(chatId, item, replyToMessageId = null) {
69
- const formData = new FormData();
70
- formData.append('chat_id', String(chatId));
71
- if (replyToMessageId) {
72
- formData.append('reply_to_message_id', String(replyToMessageId));
73
- }
74
- const file = new Blob([readFileSync(item.value)], { type: item.mime || 'application/octet-stream' });
75
- const fileName = basename(item.value);
76
- if ((item.mime || '').startsWith('image/')) {
77
- formData.append('photo', file, fileName);
78
- await apiCall('sendPhoto', formData, { formData: true });
79
- } else {
80
- formData.append('document', file, fileName);
81
- await apiCall('sendDocument', formData, { formData: true });
82
- }
83
- }
84
-
85
- async function ensureBotInfo() {
86
- const me = await apiCall('getMe', {});
87
- await stateStore.update((current) => ({
88
- ...current,
89
- botId: me.id,
90
- username: me.username || null,
91
- }));
92
- return me;
93
- }
94
-
95
- async function downloadTelegramFile(fileId) {
96
- const token = getToken();
97
- const fileInfo = await apiCall('getFile', { file_id: fileId });
98
- const filePath = fileInfo?.file_path;
99
- if (!filePath) return null;
100
- const url = `${TELEGRAM_API}/file/bot${token}/${filePath}`;
101
- const ext = extname(filePath) || '.bin';
102
- const localDir = '/tmp/ticlawk/telegram-inbound';
103
- mkdirSync(localDir, { recursive: true });
104
- const localPath = `${localDir}/${randomUUID()}${ext}`;
105
- const response = await fetch(url);
106
- const buffer = Buffer.from(await response.arrayBuffer());
107
- writeFileSync(localPath, buffer);
108
- return localPath;
109
- }
110
-
111
- async function normalizeInbound(message) {
112
- const media = [];
113
- if (Array.isArray(message.photo) && message.photo.length > 0) {
114
- const photo = message.photo[message.photo.length - 1];
115
- const localPath = await downloadTelegramFile(photo.file_id);
116
- if (localPath) {
117
- media.push({ kind: 'local_path', value: localPath, mime: 'image/jpeg' });
118
- }
119
- } else if (message.document?.mime_type?.startsWith('image/')) {
120
- const localPath = await downloadTelegramFile(message.document.file_id);
121
- if (localPath) {
122
- media.push({ kind: 'local_path', value: localPath, mime: message.document.mime_type });
123
- }
124
- }
125
- return {
126
- bindingId: `telegram:dm:${message.chat.id}`,
127
- messageId: String(message.message_id),
128
- text: message.caption || message.text || '',
129
- action: media.length > 0 ? 'image' : 'task',
130
- media,
131
- raw: message,
132
- };
133
- }
134
-
135
- function getTelegramBinding() {
136
- return ctx.getBinding(DEFAULT_BINDING_ID);
137
- }
138
-
139
- async function replyWithUnboundMessage(message, text) {
140
- const targetKey = `telegram:dm:${message.chat.id}`;
141
- const now = Date.now();
142
- const lastSentAt = unboundReplySentAt.get(targetKey) || 0;
143
- if (now - lastSentAt < UNBOUND_REPLY_COOLDOWN_MS) return;
144
- unboundReplySentAt.set(targetKey, now);
145
- await sendMessage(message.chat.id, text, message.message_id);
146
- }
147
-
148
- async function claimBinding(message, binding) {
149
- const chatId = message.chat.id;
150
- const targetKey = `telegram:dm:${chatId}`;
151
- const claimed = await ctx.upsertBinding({
152
- ...binding,
153
- targetKey,
154
- targetMeta: {
155
- chatId,
156
- userId: message.from?.id || null,
157
- username: message.from?.username || null,
158
- firstName: message.from?.first_name || null,
159
- },
160
- status: 'connected',
161
- });
162
- await sendMessage(chatId, `Connected this chat to ${claimed.displayName}.`, message.message_id);
163
- return claimed;
164
- }
165
-
166
- async function handleMessage(message) {
167
- if (!message?.chat || message.chat.type !== 'private') return;
168
- if (message.from?.is_bot) return;
169
- let binding = getTelegramBinding();
170
- if (!binding) {
171
- await replyWithUnboundMessage(
172
- message,
173
- 'This bot is online, but no local runtime is connected yet. Run `ticlawk connect --adapter telegram --workdir /path/to/project` there first. Add `--session-id <id>` only if you want to resume an existing session.'
174
- );
175
- return;
176
- }
177
- if (!binding.targetMeta?.chatId) {
178
- binding = await claimBinding(message, binding);
179
- } else if (String(binding.targetMeta.chatId) !== String(message.chat.id)) {
180
- await replyWithUnboundMessage(message, 'This bot is already connected to another chat.');
181
- return;
182
- }
183
- const inbound = await normalizeInbound(message);
184
- inbound.bindingId = binding.id;
185
- await ctx.bus.dispatchToAgent(binding.runtime, binding.id, inbound);
186
- }
187
-
188
- async function pollLoop() {
189
- if (polling) return;
190
- polling = true;
191
- while (!stopped) {
192
- try {
193
- const state = stateStore.read();
194
- const updates = await apiCall('getUpdates', {
195
- timeout: 50,
196
- offset: state.updateOffset || 0,
197
- allowed_updates: ['message'],
198
- });
199
- let nextOffset = state.updateOffset || 0;
200
- for (const update of updates || []) {
201
- nextOffset = Math.max(nextOffset, (update.update_id || 0) + 1);
202
- if (update.message) {
203
- await handleMessage(update.message);
204
- }
205
- }
206
- if (nextOffset !== (state.updateOffset || 0)) {
207
- await stateStore.update((current) => ({ ...current, updateOffset: nextOffset }));
208
- }
209
- } catch {
210
- await new Promise((resolve) => setTimeout(resolve, 3000));
211
- }
212
- }
213
- polling = false;
214
- }
215
-
216
- return {
217
- id: 'telegram',
218
-
219
- async start() {
220
- await ensureBotInfo();
221
- void pollLoop();
222
- },
223
-
224
- async health() {
225
- const binding = getTelegramBinding();
226
- const state = stateStore.read();
227
- const runtimeHealthEntries = await Promise.all(Object.entries(ctx.runtimes || {})
228
- .filter(([, runtime]) => typeof runtime?.health === 'function')
229
- .map(async ([name, runtime]) => [name, await runtime.health(binding?.runtimeMeta || {})]));
230
- const runtimesHealth = Object.fromEntries(runtimeHealthEntries);
231
- const codexHealth = runtimesHealth.codex || {};
232
- return {
233
- botConfigured: Boolean(getToken()),
234
- botId: state.botId || null,
235
- botUsername: state.username || null,
236
- runtimeConnected: Boolean(binding),
237
- connectedChat: Boolean(binding?.targetMeta?.chatId),
238
- runtimes: runtimesHealth,
239
- codexAvailable: codexHealth.available,
240
- codexPath: codexHealth.path || null,
241
- codexVersion: codexHealth.version || null,
242
- };
243
- },
244
-
245
- async connect(payload) {
246
- if (!getToken()) {
247
- return {
248
- statusCode: 400,
249
- body: {
250
- ok: false,
251
- error: 'telegram is not authenticated; run `ticlawk auth telegram --bot-token <token>` first',
252
- },
253
- };
254
- }
255
- try {
256
- const resolved = await ctx.resolveRuntimeBinding(payload);
257
- const existing = getTelegramBinding();
258
- const binding = await ctx.upsertBinding({
259
- ...(existing || {}),
260
- id: DEFAULT_BINDING_ID,
261
- adapter: 'telegram',
262
- targetKey: existing?.targetKey || UNCLAIMED_TARGET_KEY,
263
- targetMeta: existing?.targetMeta || {},
264
- runtime: resolved.runtime,
265
- runtimeMeta: resolved.runtimeMeta,
266
- displayName: resolved.displayName,
267
- status: existing?.targetMeta?.chatId ? 'connected' : 'unclaimed',
268
- });
269
- if (binding.targetMeta?.chatId) {
270
- await sendMessage(binding.targetMeta.chatId, `Bound this bot to ${binding.displayName}.`);
271
- }
272
- return {
273
- statusCode: 200,
274
- body: {
275
- ok: true,
276
- adapter: 'telegram',
277
- bindingId: binding.id,
278
- name: binding.displayName,
279
- runtime: binding.runtime,
280
- connectedChat: Boolean(binding.targetMeta?.chatId),
281
- status: binding.targetMeta?.chatId ? 'connected' : 'unclaimed',
282
- },
283
- };
284
- } catch (err) {
285
- return { statusCode: 500, body: { ok: false, error: err.message } };
286
- }
287
- },
288
-
289
- async send(binding, outbound) {
290
- const chatId = binding.targetMeta.chatId;
291
- if (!chatId) return;
292
- if (outbound.text) {
293
- await sendMessage(chatId, outbound.text, outbound.replyToMessageId || null);
294
- }
295
- for (const item of outbound.media || []) {
296
- if (item.kind !== 'local_path') continue;
297
- await sendMedia(chatId, item, outbound.replyToMessageId || null);
298
- }
299
- },
300
-
301
- async emitEvent(binding, payload) {
302
- const chatId = binding.targetMeta.chatId;
303
- if (!chatId) return;
304
- const eventName = payload?.event?.worker_event_name || payload?.event?.hook_event_name;
305
- if (eventName !== 'worker.turn.start' && eventName !== 'worker.message.delta') return;
306
- const key = String(chatId);
307
- const now = Date.now();
308
- if (now - (typingSentAt.get(key) || 0) < 4000) return;
309
- typingSentAt.set(key, now);
310
- await apiCall('sendChatAction', {
311
- chat_id: chatId,
312
- action: 'typing',
313
- }).catch(() => {});
314
- },
315
- };
316
- }
317
-
318
- export function getTelegramAuthHelp() {
319
- return `ticlawk auth telegram --bot-token <bot-token>
320
-
321
- Options:
322
- --bot-token <token> Telegram bot token from @BotFather
323
- `;
324
- }
325
-
326
- export async function runTelegramAuth(rawArgs) {
327
- const args = parseOptionArgs(rawArgs);
328
- if (args.help || args.h) {
329
- return {
330
- statusCode: 200,
331
- body: {
332
- ok: true,
333
- help: getTelegramAuthHelp(),
334
- },
335
- };
336
- }
337
- const botToken = String(args['bot-token'] || '').trim();
338
- if (!botToken) {
339
- return {
340
- statusCode: 400,
341
- body: {
342
- ok: false,
343
- error: 'telegram auth requires --bot-token',
344
- },
345
- };
346
- }
347
- persistConfig({
348
- [AF_ADAPTER_KEY]: 'telegram',
349
- TELEGRAM_BOT_TOKEN: botToken,
350
- });
351
- return {
352
- statusCode: 200,
353
- body: {
354
- ok: true,
355
- adapter: 'telegram',
356
- botToken: 'set',
357
- },
358
- };
359
- }