opencode-telegram-connect 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Terrera AG contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # OpenCode Telegram Control
2
+
3
+ Steuere eine laufende OpenCode-Session über deinen eigenen Telegram-Bot. Nachrichten werden an OpenCode weitergeleitet; Antworten, Freigaben und Rückfragen erscheinen in Telegram.
4
+
5
+ **Telegram bleibt beim Start von OpenCode ausgeschaltet.** Erst wenn du deinen Agenten zum Verbinden aufforderst und er `telegram_connect` aufruft, startet die Verbindung. Es gibt keinen Telegram-Befehl zum Einschalten. Der frühere Startbefehl wird ignoriert und nicht an das Modell weitergeleitet.
6
+
7
+ ## Voraussetzungen
8
+
9
+ - OpenCode mit npm-Plugin-Unterstützung; getestet mit OpenCode 1.18.29.
10
+ - Eine funktionierende Modellkonfiguration in OpenCode.
11
+ - Node.js ab Version 20 und npm für die unten gezeigten Hilfsbefehle.
12
+ - Telegram auf deinem Handy oder Computer und Internetzugang auf dem OpenCode-Rechner.
13
+
14
+ OpenCode muss während der Nutzung laufen. Das Plugin startet keinen zusätzlichen OpenCode-Server. Es öffnet keinen eingehenden Telegram-Port.
15
+
16
+ ## 1. Plugin installieren
17
+
18
+ Öffne die globale OpenCode-Konfiguration:
19
+
20
+ - Windows: `%USERPROFILE%\.config\opencode\opencode.json`
21
+ - Linux/macOS: `~/.config/opencode/opencode.json`
22
+
23
+ Ergänze das Paket in der vorhandenen `plugin`-Liste. Andere Einstellungen und Plugins beibehalten:
24
+
25
+ ```json
26
+ {
27
+ "$schema": "https://opencode.ai/config.json",
28
+ "plugin": ["opencode-telegram-connect@1.0.0"]
29
+ }
30
+ ```
31
+
32
+ OpenCode installiert dieses npm-Paket beim nächsten Start automatisch. Eine globale Installation allein aktiviert das Plugin nicht. Entferne alte Einträge dieses Plugins und lokale `file://`-Einbindungen, damit es nur einmal geladen wird.
33
+
34
+ Optional kannst du das Paket samt Hilfsbefehl global installieren:
35
+
36
+ ```sh
37
+ npm install -g opencode-telegram-connect@1.0.0
38
+ ```
39
+
40
+ Siehe auch die [OpenCode-Plugin-Dokumentation](https://opencode.ai/docs/plugins/).
41
+
42
+ ## 2. Bot und Token bekommen
43
+
44
+ 1. Öffne in Telegram den offiziellen [@BotFather](https://t.me/BotFather).
45
+ 2. Sende ihm `/newbot`.
46
+ 3. Wähle einen Anzeigenamen und einen verfügbaren Benutzernamen für deinen Bot. Der Benutzername endet auf `bot`.
47
+ 4. BotFather gibt dir einen API-Token. Bewahre ihn wie ein Passwort auf.
48
+ 5. Öffne den Chat mit deinem neuen Bot und sende ihm eine normale Nachricht wie `Hallo`. Eine Antwort ist zu diesem Zeitpunkt noch nicht zu erwarten.
49
+
50
+ Der Token gehört zum Bot. Deine persönliche Benutzer-ID wird im nächsten Schritt separat ermittelt. Anleitung von Telegram: [Bot erstellen](https://core.telegram.org/bots/tutorial#obtain-your-bot-token).
51
+
52
+ ## 3. Token und Benutzer-ID hinterlegen
53
+
54
+ Erstelle diese Datei:
55
+
56
+ - Windows: `C:\Users\<DEIN-NAME>\.config\opencode\telegram.env`
57
+ - Linux/macOS: `~/.config/opencode/telegram.env`
58
+
59
+ Unter Windows kannst du sie so öffnen:
60
+
61
+ ```powershell
62
+ New-Item -ItemType Directory -Force "$env:USERPROFILE\.config\opencode" | Out-Null
63
+ notepad "$env:USERPROFILE\.config\opencode\telegram.env"
64
+ ```
65
+
66
+ Trage zunächst deinen echten Bot-Token ein:
67
+
68
+ ```ini
69
+ TELEGRAM_TOKEN=DEIN_BOT_TOKEN
70
+ ```
71
+
72
+ Speichere die Datei genau als `telegram.env`, nicht als `telegram.env.txt`.
73
+
74
+ ### Deine Benutzer-ID ermitteln
75
+
76
+ Lass die Telegram-Verbindung in OpenCode ausgeschaltet. Nachdem du deinem Bot privat `Hallo` geschickt hast, führe aus:
77
+
78
+ ```sh
79
+ npx --yes --package=opencode-telegram-connect@1.0.0 opencode-telegram-user-id
80
+ ```
81
+
82
+ Der Hilfsbefehl liest den Token aus deiner Datei und zeigt die Benutzer-IDs der vorhandenen Nachrichten an, beispielsweise `DeinName: TELEGRAM_USER=123456789`. Er sendet keine Nachricht und gibt den Token nicht aus. Falls mehrere Personen angezeigt werden, nimm deine eigene ID. Falls nichts angezeigt wird, sende erneut `Hallo` und wiederhole den Hilfsbefehl. Währenddessen darf keine andere Anwendung mit diesem Bot Nachrichten abfragen.
83
+
84
+ Ergänze die Datei anschließend:
85
+
86
+ ```ini
87
+ TELEGRAM_TOKEN=DEIN_BOT_TOKEN
88
+ TELEGRAM_USER=123456789
89
+ TELEGRAM_PAIRING=false
90
+ ```
91
+
92
+ Die Benutzer-ID ist eine Zahl, kein `@Benutzername` und nicht die ID des Bots. Mehrere erlaubte Benutzer werden mit Kommas getrennt. Mit dieser Konfiguration können nur die eingetragenen Benutzer den Bot steuern.
93
+
94
+ Du brauchst keinen npm-Token für Installation oder Nutzung. Ein API-Schlüssel für dein Modell gehört in die OpenCode-Modellkonfiguration, nicht in diese Datei. Token niemals in Prompts, Screenshots oder ins Repository kopieren.
95
+
96
+ ## 4. Verbinden und erster Test
97
+
98
+ Starte OpenCode in deinem gewünschten Projektordner:
99
+
100
+ ```sh
101
+ cd pfad/zu/deinem/projekt
102
+ opencode
103
+ ```
104
+
105
+ Schreibe deinem OpenCode-Agenten:
106
+
107
+ > Verbinde diese Session mit Telegram. Verwende telegram_connect.
108
+
109
+ Erst dieser Werkzeugaufruf prüft den Token und startet das Telegram-Polling. Der Bot sendet dir jetzt automatisch die Anleitung. Daf?r musst du ihm zuvor einmal privat geschrieben haben und darfst ihn nicht blockiert haben. Ohne statische Freigabe erscheint die Anleitung erst nach erfolgreichem Pairing. Zustellfehler werden beim Verbinden gemeldet; die Verbindung bleibt nutzbar.
110
+
111
+ Sende danach deinem Bot:
112
+
113
+ ```text
114
+ /current
115
+ ```
116
+
117
+ Die Ausgabe zeigt die gewählte Session. Sende anschließend:
118
+
119
+ ```text
120
+ Antworte nur mit HALLO.
121
+ ```
122
+
123
+ Du erhältst zunächst die Session-ID und danach die Modellantwort. Das Modell arbeitet mit den Werkzeugen und Berechtigungen deiner laufenden OpenCode-Instanz.
124
+
125
+ ## 5. Bedienung in Telegram
126
+
127
+ | Nachricht/Befehl | Funktion |
128
+ | --- | --- |
129
+ | Normaler Text | Als Prompt an die gewählte Session senden |
130
+ | `/start` oder `/help` | Vollst?ndige Bedienungsanleitung anzeigen |
131
+ | `/current` | Aktuelle Session und Modell anzeigen |
132
+ | `/sessions` | Bis zu 20 Sessions auflisten |
133
+ | `/session 2` | Session aus der Liste auswählen |
134
+ | `/session <id>` | Session anhand ihrer gelisteten ID auswählen |
135
+ | `/new Mein Test` | Neue Session erstellen und auswählen |
136
+ | `/rename Neuer Titel` | Aktuelle Session umbenennen |
137
+ | `/models` | Verfügbare Modelle auflisten |
138
+ | `/model 2` | Modell aus der Liste auswählen |
139
+ | `/model anbieter/modell` | Modell anhand seines vollständigen Namens auswählen |
140
+ | `/abort` | Laufende Verarbeitung abbrechen |
141
+ | `/permissions` | Offene Freigaben anzeigen |
142
+ | `/approve <id>` | Einmal erlauben |
143
+ | `/always <id>` | Passende Berechtigung dauerhaft erlauben |
144
+ | `/deny <id>` | Berechtigung ablehnen |
145
+ | `/questions` | Offene Rückfragen anzeigen |
146
+ | `/answer <id> Antwort` | Rückfrage beantworten |
147
+ | `/rejectquestion <id>` | Rückfrage ablehnen |
148
+ | `/disconnect` | Zuordnung dieses Chats entfernen; statische Benutzerfreigaben bleiben gültig |
149
+
150
+ Für mehrere Rückfragen: `/answer <id> Antwort eins || Antwort zwei`. Bei Mehrfachauswahl: `Option A; Option B`. Bei einfachen Auswahlfragen und Freigaben kannst du die mitgesendeten Schaltflächen verwenden.
151
+
152
+ Andere Slash-Befehle werden als Text an OpenCode übermittelt; das führt nicht automatisch einen OpenCode-Slash-Befehl aus. Der entfernte Startbefehl ist ausdrücklich davon ausgenommen.
153
+
154
+ ### Telegram vollständig ausschalten
155
+
156
+ Sage deinem OpenCode-Agenten:
157
+
158
+ > Trenne Telegram. Verwende telegram_disconnect.
159
+
160
+ Das stoppt das Polling und gibt die Bot-Sperre frei. `/disconnect` in Telegram entfernt dagegen nur die Chat-Zuordnung. Für einen laufenden Auftrag zuerst `/abort` verwenden; das vollständige Trennen wartet auf bereits laufende Nachrichtenverarbeitung.
161
+
162
+ Nach einem Neustart von OpenCode musst du erneut verbinden. Nur eine OpenCode-Instanz kann gleichzeitig mit demselben Bot verbunden sein. Andere Instanzen übernehmen den Bot nicht automatisch. Trenne die bisherige Instanz und fordere dann den gewünschten Agenten zum Verbinden auf.
163
+
164
+ ## Alternative Konfiguration
165
+
166
+ Vorhandene Umgebungsvariablen haben Vorrang. Akzeptierte Namen:
167
+
168
+ | Variable | Bedeutung |
169
+ | --- | --- |
170
+ | `TELEGRAM_BOT_TOKEN` | Umgebungsvariable für den Bot-Token; in Dateien ist auch `TELEGRAM_TOKEN` erlaubt |
171
+ | `TELEGRAM_ALLOWED_USER_IDS` | Erlaubte Benutzer; in Dateien auch `TELEGRAM_USER` |
172
+ | `TELEGRAM_ALLOWED_CHAT_IDS` | Erlaubte Chats, mit Kommas getrennt |
173
+ | `TELEGRAM_PAIRING` | `true` oder `false` |
174
+ | `TELEGRAM_ENV_FILE` | Expliziter Pfad zu einer Konfigurationsdatei |
175
+
176
+ Dateisuche: expliziter Pfad, globale `telegram.env`, globale `telegram.json`, dann `.env.opencode-telegram` und `.env` im Projekt oder übergeordneten Verzeichnissen. Bevorzuge die globale Datei. Nach Änderungen OpenCode neu starten, damit zuvor geladene Werte ersetzt werden.
177
+
178
+ Alternatives JSON-Format: `{"token":"DEIN_BOT_TOKEN","userId":"123456789"}`.
179
+
180
+ Eine Chat-Freigabe erlaubt allen Benutzern dieses Chats die Steuerung. Benutzer- und Chat-Freigaben werden mit ODER verknüpft. Für private Nutzung genügt deine Benutzer-ID.
181
+
182
+ Ohne Benutzer-/Chat-Freigabe ist einmaliges Pairing möglich: `telegram_connect` liefert einen Code, den du als `/pair <code>` an deinen Bot sendest. Der Code verfällt nach Verwendung oder beim Beenden der Verbindung. Bei `TELEGRAM_PAIRING=false` muss eine Freigabe konfiguriert sein.
183
+
184
+ ## Fehlerbehebung
185
+
186
+ - **Bot antwortet nicht:** OpenCode muss laufen und `telegram_connect` erfolgreich ausgeführt worden sein. Prüfe über den Agenten `telegram_status`.
187
+ - **Unauthorized:** Prüfe deine numerische Benutzer-ID und starte OpenCode nach der Korrektur neu.
188
+ - **Tokenfehler:** Kopiere den Token aus BotFather erneut in die Datei. Prüfe Dateiname und Pfad.
189
+ - **Andere Instanz verbunden / Telegram Conflict:** Trenne andere OpenCode-Instanzen oder Programme, die denselben Bot abfragen.
190
+ - **Webhook-Konflikt:** Dieses Plugin verwendet Long Polling. Verwende einen eigenen Bot ohne bestehenden Webhook.
191
+ - **Keine Modellantwort:** Teste denselben Auftrag zuerst direkt in OpenCode. Modellzugang, offene Berechtigungen und Rückfragen prüfen.
192
+ - **Werkzeuge fehlen:** Plugin-Eintrag prüfen, alte lokale Einbindungen entfernen und OpenCode neu starten.
193
+
194
+ ## Entwicklung
195
+
196
+ ```sh
197
+ npm ci
198
+ npm test
199
+ npm run check
200
+ npm run pack:dry
201
+ ```
202
+
203
+ Die Tests prüfen Autorisierung, Pairing, Session- und Modellwahl, Berechtigungen, Fragen, das echte SDK-Requestformat mit simuliertem HTTP-Transport sowie den Verbindungslebenszyklus ohne Netzwerkzugriff.
204
+
205
+ Version 1.0.0: Erstver?ffentlichung unter dem Paketnamen `opencode-telegram-connect`. Telegram wird ausschlie?lich ?ber `telegram_connect` aktiviert. Nach dem Verbinden und Pairing erscheint die Bedienungsanleitung automatisch; `/start` und `/help` zeigen sie erneut an.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencode-telegram-connect",
3
+ "version": "1.0.0",
4
+ "description": "Native OpenCode plugin to securely control sessions from Telegram.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./src/index.js"
10
+ },
11
+ "./server": {
12
+ "import": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "opencode",
22
+ "telegram",
23
+ "plugin",
24
+ "agent",
25
+ "remote-control"
26
+ ],
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "peerDependencies": {
32
+ "@opencode-ai/plugin": ">=1.0.0"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test test/*.test.js",
36
+ "check": "node --check src/index.js && node --check src/bridge.js && node --check src/telegram.js && node --check src/opencode.js && node --check src/env.js && node --check src/lock.js && node --check src/user-id.js",
37
+ "pack:dry": "npm pack --dry-run"
38
+ },
39
+ "dependencies": {
40
+ "@opencode-ai/sdk": "1.18.28"
41
+ },
42
+ "bin": {
43
+ "opencode-telegram-user-id": "src/user-id.js"
44
+ }
45
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,391 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { extractText, normalizeProviders, normalizeSessions, promptSession, replyPermission, replyQuestion, rejectQuestion, unwrap } from "./opencode.js";
3
+
4
+ const CMD_HELP = `OpenCode ist mit Telegram verbunden.
5
+
6
+ Sende normalen Text, um in der verbundenen Session zu arbeiten. Beispiel: Erkl?re mir dieses Projekt.
7
+
8
+ /start oder /help - diese Anleitung
9
+ /current - aktuelle Session und Modell
10
+ /sessions - Sessions auflisten
11
+ /session <Nummer|ID> - Session ausw?hlen
12
+ /new [Titel] - neue Session erstellen
13
+ /rename <Titel> - Session umbenennen
14
+ /models - Modelle auflisten
15
+ /model <Nummer|Anbieter/Modell> - Modell ausw?hlen
16
+ /abort - laufenden Auftrag abbrechen
17
+ /permissions - offene Freigaben
18
+ /approve <ID> - einmal erlauben
19
+ /always <ID> - passende Berechtigung dauerhaft erlauben
20
+ /deny <ID> - Freigabe ablehnen
21
+ /questions - offene R?ckfragen
22
+ /answer <ID> <Antwort> - R?ckfrage beantworten
23
+ /rejectquestion <ID> - R?ckfrage ablehnen
24
+ /disconnect - Chat-Zuordnung entfernen (statische Freigaben bleiben g?ltig)
25
+ /pair <Code> - einmaliges Pairing, falls keine Benutzer-/Chat-Freigabe eingerichtet ist
26
+
27
+ Mehrere Antworten mit || trennen; Mehrfachauswahl mit ;. Freigaben und einfache Auswahlfragen lassen sich auch ?ber Schaltfl?chen beantworten.
28
+
29
+ OpenCode muss weiterlaufen. Vollst?ndig ausschalten: Deinen OpenCode-Agenten bitten, telegram_disconnect aufzurufen. Erneut verbinden: telegram_connect. /start zeigt nur diese Hilfe und kann eine ausgeschaltete Verbindung nicht einschalten.
30
+
31
+ Andere Slash-Befehle werden als Prompt-Text an das Modell weitergeleitet.`;
32
+
33
+ function idOfSession(s) { return s?.id || s?.sessionID; }
34
+ async function callApi(fn, options) {
35
+ const result = await fn(options);
36
+ if (result && typeof result === "object" && "error" in result && result.error) {
37
+ const e = result.error;
38
+ throw new Error(e.message || e.name || e._tag || JSON.stringify(e));
39
+ }
40
+ return result;
41
+ }
42
+ function titleOfSession(s) { return s?.title || s?.name || "Untitled"; }
43
+ function parseIds(value) {
44
+ return new Set(String(value || "").split(",").map((x) => x.trim()).filter(Boolean));
45
+ }
46
+
47
+ export class Bridge {
48
+ constructor({ client, telegram, env = process.env, logger = async () => {} }) {
49
+ this.client = client;
50
+ this.telegram = telegram;
51
+ this.logger = logger;
52
+ this.allowedUsers = parseIds(env.TELEGRAM_ALLOWED_USER_IDS);
53
+ this.allowedChats = parseIds(env.TELEGRAM_ALLOWED_CHAT_IDS);
54
+ this.pairingEnabled = String(env.TELEGRAM_PAIRING || "true").toLowerCase() !== "false";
55
+ this.pairedUsers = new Set();
56
+ this.pairedChats = new Set();
57
+ this.bindings = new Map();
58
+ this.pendingPermissions = new Map();
59
+ this.pendingQuestions = new Map();
60
+ this.pairCode = null;
61
+ this.pollAbort = null;
62
+ this.pollPromise = null;
63
+ this.offset = 0;
64
+ this.inflightUpdates = new Set();
65
+ }
66
+
67
+ isAuthorized(userId, chatId) {
68
+ const u = String(userId ?? "");
69
+ const c = String(chatId ?? "");
70
+ if (this.allowedUsers.size || this.allowedChats.size) {
71
+ return this.allowedUsers.has(u) || this.allowedChats.has(c);
72
+ }
73
+ return this.pairedUsers.has(u) && this.pairedChats.has(c);
74
+ }
75
+
76
+ startPairing(sessionID) {
77
+ if (!this.pairingEnabled && !this.allowedUsers.size && !this.allowedChats.size) {
78
+ throw new Error("No Telegram allowlist configured and pairing is disabled");
79
+ }
80
+ this.defaultSessionID = sessionID;
81
+ for (const binding of this.bindings.values()) binding.sessionID = sessionID;
82
+ if (this.allowedUsers.size || this.allowedChats.size) {
83
+ return { code: null, message: "Telegram bridge active. Configured allowlist will be used." };
84
+ }
85
+ this.pairCode = String(randomInt(10000000, 100000000));
86
+ this.defaultSessionID = sessionID;
87
+ return { code: this.pairCode, message: `Telegram bridge active. Send /pair ${this.pairCode} to the bot. The code is one-time and expires when used or OpenCode exits.` };
88
+ }
89
+
90
+ async sendGuide(chatId) {
91
+ return this.telegram.sendMessage(chatId, CMD_HELP);
92
+ }
93
+
94
+ async announceConnection() {
95
+ const targets = new Set([...this.allowedUsers, ...this.allowedChats, ...this.bindings.keys()]);
96
+ const failed = [];
97
+ for (const chatId of targets) {
98
+ try {
99
+ await this.sendGuide(chatId);
100
+ this.binding(chatId);
101
+ } catch {
102
+ failed.push(chatId);
103
+ await this.logger("warn", "Could not deliver Telegram connection guide; user must message the bot first or unblock it.");
104
+ }
105
+ }
106
+ return failed.length ? " Anleitung konnte nicht an alle Chats zugestellt werden. ?ffne den Bot, sende ihm eine Nachricht und rufe /start bei aktiver Verbindung auf." : "";
107
+ }
108
+
109
+ async start() {
110
+ if (this.pollPromise) return;
111
+ this.pollAbort = new AbortController();
112
+ this.pollPromise = this.pollLoop(this.pollAbort.signal).finally(() => { this.pollPromise = null; });
113
+ }
114
+
115
+ async stop() {
116
+ if (!this.pollAbort) return;
117
+ this.pollAbort.abort();
118
+ try { await this.pollPromise; } catch (error) { if (error?.name !== "AbortError") throw error; }
119
+ await Promise.allSettled([...this.inflightUpdates]);
120
+ this.pollAbort = null;
121
+ }
122
+
123
+ async pollLoop(signal) {
124
+ while (!signal.aborted) {
125
+ try {
126
+ const updates = await this.telegram.getUpdates(this.offset, 25, signal);
127
+ if (!Array.isArray(updates)) throw new Error(`Telegram getUpdates returned non-array: ${JSON.stringify(updates)?.slice(0, 300)}`);
128
+ for (const update of updates || []) {
129
+ this.offset = Math.max(this.offset, Number(update.update_id || 0) + 1);
130
+ const task = this.handleUpdate(update)
131
+ .catch((error) => this.logger("error", `Telegram update error: ${error.message}`))
132
+ .finally(() => this.inflightUpdates.delete(task));
133
+ this.inflightUpdates.add(task);
134
+ }
135
+ } catch (error) {
136
+ if (signal.aborted) return;
137
+ const conflict = /Conflict/i.test(error.message || "");
138
+ await this.logger(conflict ? "warn" : "error", `Telegram polling error: ${error.message}`);
139
+ await new Promise((resolve) => setTimeout(resolve, conflict ? 5000 : 1200));
140
+ }
141
+ }
142
+ }
143
+
144
+ async handleUpdate(update) {
145
+ if (update.callback_query) return this.handleCallback(update.callback_query);
146
+ const msg = update.message;
147
+ if (!msg || typeof msg.text !== "string") return;
148
+ const chatId = msg.chat?.id;
149
+ const userId = msg.from?.id;
150
+ const text = msg.text;
151
+
152
+ if (!this.client?.session?.create) {
153
+ await this.telegram.sendMessage(chatId, "OpenCode client is not connected in this plugin host; the bridge cannot proxy prompts.");
154
+ return;
155
+ }
156
+
157
+ if (text.startsWith("/pair ")) {
158
+ const code = text.slice(6).trim();
159
+ if (!this.pairingEnabled || !this.pairCode || code !== this.pairCode) {
160
+ await this.telegram.sendMessage(chatId, "Pairing failed.");
161
+ return;
162
+ }
163
+ this.pairedUsers.add(String(userId));
164
+ this.pairedChats.add(String(chatId));
165
+ this.pairCode = null;
166
+ this.bindings.set(String(chatId), { sessionID: this.defaultSessionID, model: null });
167
+ await this.sendGuide(chatId);
168
+ return;
169
+ }
170
+
171
+ if (!this.isAuthorized(userId, chatId)) {
172
+ await this.telegram.sendMessage(chatId, "Unauthorized Telegram user/chat.");
173
+ return;
174
+ }
175
+
176
+ if (text.startsWith("/")) return this.handleCommand(chatId, text);
177
+ return this.forwardPrompt(chatId, text);
178
+ }
179
+
180
+ binding(chatId) {
181
+ const key = String(chatId);
182
+ if (!this.bindings.has(key)) this.bindings.set(key, { sessionID: this.defaultSessionID || null, model: null });
183
+ return this.bindings.get(key);
184
+ }
185
+
186
+ async ensureSession(chatId) {
187
+ const binding = this.binding(chatId);
188
+ if (binding.sessionID) return binding.sessionID;
189
+ const session = unwrap(await callApi((o) => this.client.session.create(o), { title: "Telegram" }));
190
+ binding.sessionID = idOfSession(session);
191
+ return binding.sessionID;
192
+ }
193
+
194
+ async forwardPrompt(chatId, text) {
195
+ const binding = this.binding(chatId);
196
+ const sessionID = await this.ensureSession(chatId);
197
+ await this.telegram.sendMessage(chatId, `→ ${sessionID}`);
198
+ try {
199
+ const result = await promptSession(this.client, sessionID, text, binding.model);
200
+ const answer = extractText(result) || "OpenCode returned no text response.";
201
+ await this.telegram.sendMessage(chatId, answer);
202
+ } catch (error) {
203
+ await this.telegram.sendMessage(chatId, `OpenCode error: ${error.message}`);
204
+ }
205
+ }
206
+
207
+ async handleCommand(chatId, raw) {
208
+ const [head, ...rest] = raw.trim().split(/\s+/);
209
+ const command = head.toLowerCase().split("@")[0];
210
+ const arg = raw.trim().slice(head.length).trim();
211
+ const binding = this.binding(chatId);
212
+
213
+ if (command === "/start" || command === "/help") return this.sendGuide(chatId);
214
+ if (command === "/sessions") {
215
+ const sessions = normalizeSessions(await callApi((o) => this.client.session.list(o), undefined)).slice(0, 20);
216
+ const lines = sessions.map((s, i) => `${i + 1}. ${idOfSession(s) === binding.sessionID ? "*" : " "} ${titleOfSession(s)} — ${idOfSession(s)}`);
217
+ return this.telegram.sendMessage(chatId, lines.length ? lines.join("\n") : "No sessions.");
218
+ }
219
+ if (command === "/session") {
220
+ if (!arg) return this.telegram.sendMessage(chatId, "Usage: /session <id|number>");
221
+ const sessions = normalizeSessions(await callApi((o) => this.client.session.list(o), undefined)).slice(0, 20);
222
+ const n = Number(arg);
223
+ const selected = Number.isInteger(n) && n > 0 ? sessions[n - 1] : sessions.find((s) => idOfSession(s) === arg);
224
+ if (!selected) return this.telegram.sendMessage(chatId, "Session not found.");
225
+ binding.sessionID = idOfSession(selected);
226
+ return this.telegram.sendMessage(chatId, `Selected session: ${titleOfSession(selected)} (${binding.sessionID})`);
227
+ }
228
+ if (command === "/new") {
229
+ const created = unwrap(await callApi((o) => this.client.session.create(o), { title: arg || "Telegram" }));
230
+ binding.sessionID = idOfSession(created);
231
+ return this.telegram.sendMessage(chatId, `Created and selected: ${titleOfSession(created)} (${binding.sessionID})`);
232
+ }
233
+ if (command === "/models") {
234
+ const models = normalizeProviders(await callApi((o) => this.client.config.providers(o), undefined));
235
+ const lines = models.map((m, i) => `${i + 1}. ${binding.model?.providerID === m.providerID && binding.model?.modelID === m.modelID ? "*" : " "} ${m.label}`);
236
+ return this.telegram.sendMessage(chatId, lines.length ? lines.join("\n") : "No models reported by OpenCode.");
237
+ }
238
+ if (command === "/model") {
239
+ if (!arg) return this.telegram.sendMessage(chatId, "Usage: /model <provider/model|number>");
240
+ const models = normalizeProviders(await callApi((o) => this.client.config.providers(o), undefined));
241
+ const n = Number(arg);
242
+ const selected = Number.isInteger(n) && n > 0 ? models[n - 1] : models.find((m) => m.label === arg);
243
+ if (!selected) return this.telegram.sendMessage(chatId, "Model not found.");
244
+ binding.model = selected;
245
+ return this.telegram.sendMessage(chatId, `Selected model for Telegram prompts: ${selected.label}`);
246
+ }
247
+ if (command === "/current") {
248
+ return this.telegram.sendMessage(chatId, `Session: ${binding.sessionID || "auto"}\nModel: ${binding.model?.label || "OpenCode session/default"}`);
249
+ }
250
+ if (command === "/abort") {
251
+ const id = await this.ensureSession(chatId);
252
+ await callApi((o) => this.client.session.abort(o), { sessionID: id });
253
+ return this.telegram.sendMessage(chatId, `Abort requested for ${id}.`);
254
+ }
255
+ if (command === "/rename") {
256
+ if (!arg) return this.telegram.sendMessage(chatId, "Usage: /rename <title>");
257
+ const id = await this.ensureSession(chatId);
258
+ await callApi((o) => this.client.session.update(o), { sessionID: id, title: arg });
259
+ return this.telegram.sendMessage(chatId, `Renamed ${id} to ${arg}.`);
260
+ }
261
+ if (command === "/permissions") {
262
+ const list = [...this.pendingPermissions.values()].filter((p) => !binding.sessionID || p.sessionID === binding.sessionID);
263
+ if (!list.length) return this.telegram.sendMessage(chatId, "No pending permissions.");
264
+ return this.telegram.sendMessage(chatId, list.map((p) => `${p.id}: ${p.permission || p.title || "permission"} ${Array.isArray(p.patterns) ? p.patterns.join(", ") : ""}`).join("\n"));
265
+ }
266
+ if (command === "/questions") {
267
+ const list = [...this.pendingQuestions.values()].filter((q) => !binding.sessionID || q.sessionID === binding.sessionID);
268
+ if (!list.length) return this.telegram.sendMessage(chatId, "No pending questions.");
269
+ return this.telegram.sendMessage(chatId, list.map((q) => `${q.id}: ${(q.questions || []).map((x) => x.question || x.header || "Question").join(" | ")}`).join("\n"));
270
+ }
271
+ if (command === "/answer") {
272
+ const firstSpace = arg.indexOf(" ");
273
+ if (firstSpace < 1) return this.telegram.sendMessage(chatId, "Usage: /answer <question-id> <answer1> || <answer2>");
274
+ const id = arg.slice(0, firstSpace).trim();
275
+ const text = arg.slice(firstSpace + 1);
276
+ const pending = this.pendingQuestions.get(id);
277
+ if (!pending) return this.telegram.sendMessage(chatId, "Question not found or no longer pending.");
278
+ const segments = text.split(/\s+\|\|\s+/);
279
+ const questions = pending.questions || [];
280
+ if (questions.length > 1 && segments.length !== questions.length) {
281
+ return this.telegram.sendMessage(chatId, `Expected ${questions.length} answers separated by ||.`);
282
+ }
283
+ const answers = (questions.length > 1 ? segments : [text]).map((x) => x.split(/\s*;\s*/).filter(Boolean));
284
+ await replyQuestion(this.client, id, answers);
285
+ this.pendingQuestions.delete(id);
286
+ return this.telegram.sendMessage(chatId, `Question ${id} answered.`);
287
+ }
288
+ if (command === "/rejectquestion") {
289
+ if (!arg) return this.telegram.sendMessage(chatId, "Usage: /rejectquestion <question-id>");
290
+ if (!this.pendingQuestions.has(arg)) return this.telegram.sendMessage(chatId, "Question not found or no longer pending.");
291
+ await rejectQuestion(this.client, arg);
292
+ this.pendingQuestions.delete(arg);
293
+ return this.telegram.sendMessage(chatId, `Question ${arg} rejected.`);
294
+ }
295
+ if (["/approve", "/always", "/deny"].includes(command)) {
296
+ if (!arg) return this.telegram.sendMessage(chatId, `Usage: ${command} <permission-id>`);
297
+ const pending = this.pendingPermissions.get(arg);
298
+ if (!pending) return this.telegram.sendMessage(chatId, "Permission not found or no longer pending.");
299
+ const reply = command === "/approve" ? "once" : command === "/always" ? "always" : "reject";
300
+ await replyPermission(this.client, pending.sessionID, pending.id, reply);
301
+ this.pendingPermissions.delete(arg);
302
+ return this.telegram.sendMessage(chatId, `Permission ${arg}: ${reply}.`);
303
+ }
304
+ if (command === "/disconnect") {
305
+ this.pairedChats.delete(String(chatId));
306
+ this.bindings.delete(String(chatId));
307
+ return this.telegram.sendMessage(chatId, "Telegram chat disconnected from OpenCode.");
308
+ }
309
+
310
+ // Unknown slash commands are forwarded unchanged, preserving OpenCode custom commands.
311
+ return this.forwardPrompt(chatId, raw);
312
+ }
313
+
314
+ async handleCallback(query) {
315
+ const chatId = query.message?.chat?.id;
316
+ const userId = query.from?.id;
317
+ if (!this.isAuthorized(userId, chatId)) return this.telegram.answerCallbackQuery(query.id, "Unauthorized");
318
+ const data = String(query.data || "");
319
+ const qmatch = /^q:([^:]+):(\d+)$/.exec(data);
320
+ if (qmatch) {
321
+ const pending = this.pendingQuestions.get(qmatch[1]);
322
+ if (!pending) return this.telegram.answerCallbackQuery(query.id, "Question expired");
323
+ const option = pending.questions?.[0]?.options?.[Number(qmatch[2])];
324
+ const answer = option?.label ?? option?.value;
325
+ if (answer == null) return this.telegram.answerCallbackQuery(query.id, "Option expired");
326
+ await replyQuestion(this.client, pending.id, [[String(answer)]]);
327
+ this.pendingQuestions.delete(pending.id);
328
+ return this.telegram.answerCallbackQuery(query.id, "Answered");
329
+ }
330
+ const match = /^(once|always|reject):(.+)$/.exec(data);
331
+ if (!match) return this.telegram.answerCallbackQuery(query.id, "Unknown action");
332
+ const pending = this.pendingPermissions.get(match[2]);
333
+ if (!pending) return this.telegram.answerCallbackQuery(query.id, "Permission expired");
334
+ await replyPermission(this.client, pending.sessionID, pending.id, match[1]);
335
+ this.pendingPermissions.delete(pending.id);
336
+ await this.telegram.answerCallbackQuery(query.id, match[1]);
337
+ }
338
+
339
+ async onEvent(event) {
340
+ if (!event || typeof event.type !== "string") return;
341
+ if (event.type === "permission.asked" || event.type === "permission.v2.asked") {
342
+ const p = event.properties || {};
343
+ if (!p.id || !p.sessionID) return;
344
+ this.pendingPermissions.set(p.id, p);
345
+ for (const [chatId, binding] of this.bindings) {
346
+ if (binding.sessionID !== p.sessionID) continue;
347
+ const patterns = Array.isArray(p.patterns) ? `\n${p.patterns.join("\n")}` : "";
348
+ await this.telegram.sendMessage(chatId, `Permission required\nID: ${p.id}\n${p.permission || p.title || "permission"}${patterns}`, {
349
+ reply_markup: { inline_keyboard: [[
350
+ { text: "Allow once", callback_data: `once:${p.id}` },
351
+ { text: "Always", callback_data: `always:${p.id}` },
352
+ { text: "Reject", callback_data: `reject:${p.id}` },
353
+ ]] },
354
+ });
355
+ }
356
+ }
357
+ if (event.type === "question.asked") {
358
+ const q = event.properties || {};
359
+ if (!q.id || !q.sessionID) return;
360
+ this.pendingQuestions.set(q.id, q);
361
+ for (const [chatId, binding] of this.bindings) {
362
+ if (binding.sessionID !== q.sessionID) continue;
363
+ const questions = q.questions || [];
364
+ let text = `OpenCode question\nID: ${q.id}`;
365
+ questions.forEach((item, i) => {
366
+ text += `\n\n${i + 1}. ${item.question || item.header || "Question"}`;
367
+ if (Array.isArray(item.options)) text += `\n${item.options.map((o, j) => ` ${j + 1}) ${o.label || o.value}`).join("\n")}`;
368
+ if (item.multiple) text += "\n(multiple: separate choices with ;)";
369
+ });
370
+ text += `\n\nReply: /answer ${q.id} <answer1> || <answer2>`;
371
+ const extra = {};
372
+ if (questions.length === 1 && !questions[0]?.multiple && Array.isArray(questions[0]?.options) && questions[0].options.length <= 8) {
373
+ extra.reply_markup = { inline_keyboard: questions[0].options.map((o, i) => [{ text: String(o.label || o.value).slice(0, 40), callback_data: `q:${q.id}:${i}` }]) };
374
+ }
375
+ await this.telegram.sendMessage(chatId, text, extra);
376
+ }
377
+ }
378
+ if (event.type === "question.replied" || event.type === "question.rejected") {
379
+ const id = event.properties?.requestID || event.properties?.id;
380
+ if (id) this.pendingQuestions.delete(id);
381
+ }
382
+ if (event.type === "session.error") {
383
+ const p = event.properties || {};
384
+ for (const [chatId, binding] of this.bindings) {
385
+ if (p.sessionID && binding.sessionID === p.sessionID) {
386
+ await this.telegram.sendMessage(chatId, `Session error: ${p.error?.message || p.error?.name || "unknown error"}`);
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
package/src/env.js ADDED
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ export function parseEnvFile(content) {
6
+ const out = {};
7
+ for (const line of content.split(/\r?\n/)) {
8
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
9
+ if (!m) continue;
10
+ let value = m[2];
11
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
12
+ value = value.slice(1, -1);
13
+ } else {
14
+ value = value.replace(/\s+#.*$/, "").trim();
15
+ }
16
+ out[m[1]] = value;
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export function findUp(name, startDir) {
22
+ let dir = resolve(startDir || process.cwd());
23
+ for (let i = 0; i < 10; i++) {
24
+ const candidate = join(dir, name);
25
+ if (existsSync(candidate)) return candidate;
26
+ const parent = dirname(dir);
27
+ if (parent === dir) break;
28
+ dir = parent;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function loadTelegramEnv(startDir, homeDir) {
34
+ const env = process.env;
35
+ const home = homeDir || homedir();
36
+ const candidates = [
37
+ env.TELEGRAM_ENV_FILE,
38
+ join(home, ".config", "opencode", "telegram.env"),
39
+ join(home, ".config", "opencode", "telegram.json"),
40
+ findUp(".env.opencode-telegram", startDir),
41
+ findUp(".env", startDir),
42
+ ].filter(Boolean);
43
+ for (const file of candidates) {
44
+ try {
45
+ if (!existsSync(file)) continue;
46
+ const raw = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
47
+ let parsed = {};
48
+ if (file.endsWith(".json")) {
49
+ const json = JSON.parse(raw);
50
+ parsed = {
51
+ TELEGRAM_BOT_TOKEN: json.token || json.botToken || json.TELEGRAM_BOT_TOKEN,
52
+ TELEGRAM_ALLOWED_USER_IDS: json.userId || json.allowedUsers || json.TELEGRAM_ALLOWED_USER_IDS,
53
+ };
54
+ } else {
55
+ parsed = parseEnvFile(raw);
56
+ }
57
+ for (const key of ["TELEGRAM_ALLOWED_CHAT_IDS", "TELEGRAM_PAIRING"]) {
58
+ if (!env[key] && parsed[key] !== undefined) env[key] = String(parsed[key]);
59
+ }
60
+ const token = parsed.TELEGRAM_BOT_TOKEN || parsed.TELEGRAM_TOKEN;
61
+ const users = parsed.TELEGRAM_ALLOWED_USER_IDS || parsed.TELEGRAM_USER || parsed.TELEGRAM_ALLOWED_USERS;
62
+ if (!env.TELEGRAM_BOT_TOKEN && token) env.TELEGRAM_BOT_TOKEN = token;
63
+ if (!env.TELEGRAM_ALLOWED_USER_IDS && users) env.TELEGRAM_ALLOWED_USER_IDS = String(users);
64
+ if (env.TELEGRAM_BOT_TOKEN) return file;
65
+ } catch {}
66
+ }
67
+ return null;
68
+ }
package/src/index.js ADDED
@@ -0,0 +1,119 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { acquirePollLock } from "./lock.js";
3
+ import { loadTelegramEnv } from "./env.js";
4
+ import { Bridge } from "./bridge.js";
5
+ import { TelegramApi } from "./telegram.js";
6
+
7
+ let singleton;
8
+ let pollLock;
9
+ let lifecycle = Promise.resolve();
10
+ function serialize(action) {
11
+ const result = lifecycle.then(action);
12
+ lifecycle = result.catch(() => {});
13
+ return result;
14
+ }
15
+
16
+ async function buildClient(ctx, log) {
17
+ const { client, directory, serverUrl } = ctx;
18
+ const v1Config = client?._client?.getConfig?.() ?? {};
19
+ const inProcessFetch = typeof v1Config.fetch === "function" ? v1Config.fetch : undefined;
20
+ const baseUrl = (typeof v1Config.baseUrl === "string" && v1Config.baseUrl) || (typeof serverUrl === "string" && serverUrl) || "http://localhost:4096";
21
+ let sdk;
22
+ try {
23
+ sdk = await withTimeout(import("@opencode-ai/sdk/v2/client").catch(() => import("@opencode-ai/sdk/v2")), 10000, "SDK import");
24
+ } catch (e) {
25
+ await log("warn", `SDK v2 import failed: ${e.message}`);
26
+ throw new Error("OpenCode SDK v2 is required to start the Telegram bridge");
27
+ }
28
+ const factory = sdk.createOpencodeClient || sdk.default?.createOpencodeClient;
29
+ if (factory) {
30
+ const v2 = factory({ ...v1Config, baseUrl, throwOnError: true, ...(directory ? { directory } : {}), ...(inProcessFetch ? { fetch: inProcessFetch } : {}) });
31
+ return { client: v2, via: inProcessFetch ? "v2-inprocess" : "v2-http" };
32
+ }
33
+ throw new Error("OpenCode SDK v2 client factory is unavailable");
34
+ }
35
+
36
+ async function withTimeout(promise, ms, label) {
37
+ let timer;
38
+ try {
39
+ return await Promise.race([promise, new Promise((_, reject) => {
40
+ timer = setTimeout(() => reject(new Error(label + " timed out")), ms);
41
+ })]);
42
+ } finally { clearTimeout(timer); }
43
+ }
44
+
45
+ export const TelegramControlPlugin = async (ctx) => {
46
+ const { client } = ctx;
47
+ const envFile = loadTelegramEnv(ctx.directory);
48
+ const log = async (level, message, extra = {}) => {
49
+ try {
50
+ await client.app.log({ body: { service: "opencode-telegram-connect", level, message, extra } });
51
+ } catch {}
52
+ };
53
+ if (envFile) await log("info", `Telegram env loaded from ${envFile}`);
54
+
55
+ return {
56
+ event: async ({ event }) => {
57
+ if (singleton) await singleton.onEvent(event);
58
+ },
59
+ tool: {
60
+ telegram_connect: tool({
61
+ description: "Connect this OpenCode session to the configured Telegram bot. Use this when the user asks to connect, control, continue, or use this session from Telegram.",
62
+ args: {},
63
+ async execute(_args, context) {
64
+ return serialize(async () => {
65
+ loadTelegramEnv(ctx.directory);
66
+ if (!process.env.TELEGRAM_BOT_TOKEN) throw new Error("Configure the Telegram token before connecting.");
67
+ if (singleton) {
68
+ const result = singleton.startPairing(context.sessionID);
69
+ return result.message + await singleton.announceConnection();
70
+ }
71
+ const telegram = new TelegramApi(process.env.TELEGRAM_BOT_TOKEN);
72
+ const me = await telegram.call("getMe", {}, AbortSignal.timeout(10000));
73
+ const lock = acquirePollLock(String(me.id));
74
+ if (!lock.owned) throw new Error("Another OpenCode instance is connected to this bot. Disconnect it first, then retry telegram_connect.");
75
+ try {
76
+ const built = await buildClient(ctx, log);
77
+ const bridge = new Bridge({ client: built.client, telegram, logger: log });
78
+ const result = bridge.startPairing(context.sessionID);
79
+ const notice = await bridge.announceConnection();
80
+ await bridge.start();
81
+ singleton = bridge;
82
+ pollLock = lock;
83
+ await log("info", "Telegram bridge polling started by telegram_connect");
84
+ return result.message + notice;
85
+ } catch (error) {
86
+ lock.release();
87
+ throw error;
88
+ }
89
+ });
90
+ },
91
+ }),
92
+ telegram_status: tool({
93
+ description: "Show whether the OpenCode Telegram control bridge is configured and running.",
94
+ args: {},
95
+ async execute(_args, context) {
96
+ if (!process.env.TELEGRAM_BOT_TOKEN) return "Telegram is not configured. TELEGRAM_BOT_TOKEN is missing.";
97
+ if (!singleton) return "Telegram is inactive. Ask the agent to call telegram_connect to start it.";
98
+ return `Telegram bridge is running. Session: ${context.sessionID}. Authorized chats: ${singleton.bindings.size}.`;
99
+ },
100
+ }),
101
+ telegram_disconnect: tool({
102
+ description: "Stop Telegram polling and disconnect this OpenCode plugin instance.",
103
+ args: {},
104
+ async execute() {
105
+ return serialize(async () => {
106
+ if (!singleton) return "Telegram is already inactive.";
107
+ await singleton.stop();
108
+ singleton = undefined;
109
+ pollLock?.release();
110
+ pollLock = undefined;
111
+ return "Telegram stopped. Call telegram_connect to reconnect.";
112
+ });
113
+ },
114
+ }),
115
+ },
116
+ };
117
+ };
118
+
119
+ export default TelegramControlPlugin;
package/src/lock.js ADDED
@@ -0,0 +1,32 @@
1
+ import { closeSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // A live owner must never lose its lock just because its event loop is busy.
6
+ export function acquirePollLock(key, directory = tmpdir()) {
7
+ const file = join(directory, `opencode-telegram-connect-${key}.lock`);
8
+ const mine = `${process.pid}:${Date.now()}`;
9
+ for (let attempt = 0; attempt < 2; attempt++) {
10
+ try {
11
+ const fd = openSync(file, "wx");
12
+ try { writeFileSync(fd, mine); } finally { closeSync(fd); }
13
+ const release = () => {
14
+ try { if (readFileSync(file, "utf8") === mine) unlinkSync(file); } catch {}
15
+ process.removeListener("exit", release);
16
+ };
17
+ process.once("exit", release);
18
+ return { file, owned: true, release };
19
+ } catch (error) {
20
+ if (error.code !== "EEXIST") return { file, owned: false };
21
+ try {
22
+ const owner = readFileSync(file, "utf8");
23
+ const pid = Number(owner.split(":")[0]);
24
+ if (!Number.isInteger(pid) || pid <= 0) return { file, owned: false };
25
+ try { process.kill(pid, 0); return { file, owned: false }; }
26
+ catch (e) { if (e.code !== "ESRCH") return { file, owned: false }; }
27
+ if (readFileSync(file, "utf8") === owner) unlinkSync(file);
28
+ } catch { return { file, owned: false }; }
29
+ }
30
+ }
31
+ return { file, owned: false };
32
+ }
@@ -0,0 +1,87 @@
1
+ export function unwrap(value) {
2
+ return value && typeof value === "object" && "data" in value ? value.data : value;
3
+ }
4
+
5
+ export function extractText(result) {
6
+ const value = unwrap(result);
7
+ const parts = value?.parts || value?.message?.parts || [];
8
+ const texts = parts
9
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
10
+ .map((p) => p.text);
11
+ if (texts.length) return texts.join("\n");
12
+ if (typeof value?.text === "string") return value.text;
13
+ return "";
14
+ }
15
+
16
+ export function normalizeSessions(result) {
17
+ const value = unwrap(result);
18
+ return Array.isArray(value) ? value : value?.sessions || [];
19
+ }
20
+
21
+ export function normalizeProviders(result) {
22
+ const value = unwrap(result) || {};
23
+ const providers = value.providers || [];
24
+ const models = [];
25
+ for (const provider of providers) {
26
+ const providerID = provider.id || provider.providerID || provider.name;
27
+ const list = Array.isArray(provider.models)
28
+ ? provider.models
29
+ : provider.models && typeof provider.models === "object"
30
+ ? Object.values(provider.models)
31
+ : [];
32
+ for (const model of list) {
33
+ const modelID = model.id || model.modelID || model.name;
34
+ if (providerID && modelID) {
35
+ models.push({ providerID, modelID, label: `${providerID}/${modelID}` });
36
+ }
37
+ }
38
+ }
39
+ return models;
40
+ }
41
+
42
+ export async function promptSession(client, sessionID, text, model) {
43
+ const body = { parts: [{ type: "text", text }] };
44
+ if (model) body.model = { providerID: model.providerID, modelID: model.modelID };
45
+ const result = await client.session.prompt({ sessionID, ...body });
46
+ if (result && typeof result === "object" && "error" in result && result.error) {
47
+ const e = result.error;
48
+ throw new Error(e.message || e.name || e._tag || JSON.stringify(e));
49
+ }
50
+ return result;
51
+ }
52
+
53
+ export async function replyPermission(client, sessionID, requestID, reply) {
54
+ if (typeof client.permission?.reply === "function") {
55
+ return client.permission.reply({ sessionID, requestID, reply });
56
+ }
57
+ if (typeof client.permission?.respond === "function") {
58
+ return client.permission.respond({ sessionID, permissionID: requestID, response: reply });
59
+ }
60
+ if (typeof client.postSessionByIdPermissionsByPermissionId === "function") {
61
+ return client.postSessionByIdPermissionsByPermissionId({
62
+ path: { id: sessionID, permissionID: requestID },
63
+ body: { response: reply },
64
+ });
65
+ }
66
+ if (typeof client.session?.permissionReply === "function") {
67
+ return client.session.permissionReply({
68
+ path: { id: sessionID, requestID },
69
+ body: { reply },
70
+ });
71
+ }
72
+ throw new Error("OpenCode permission reply API is unavailable in this version");
73
+ }
74
+
75
+ export async function replyQuestion(client, requestID, answers) {
76
+ if (typeof client.question?.reply === "function") {
77
+ return client.question.reply({ requestID, answers });
78
+ }
79
+ throw new Error("OpenCode question reply API is unavailable in this version");
80
+ }
81
+
82
+ export async function rejectQuestion(client, requestID) {
83
+ if (typeof client.question?.reject === "function") {
84
+ return client.question.reject({ requestID });
85
+ }
86
+ throw new Error("OpenCode question reject API is unavailable in this version");
87
+ }
@@ -0,0 +1,63 @@
1
+ const MAX_TELEGRAM_TEXT = 4096;
2
+
3
+ export function splitTelegramText(text, limit = MAX_TELEGRAM_TEXT) {
4
+ const value = String(text ?? "");
5
+ if (value.length <= limit) return [value];
6
+ const chunks = [];
7
+ let rest = value;
8
+ while (rest.length > limit) {
9
+ let cut = rest.lastIndexOf("\n", limit);
10
+ if (cut < Math.floor(limit * 0.6)) cut = rest.lastIndexOf(" ", limit);
11
+ if (cut < Math.floor(limit * 0.6)) cut = limit;
12
+ chunks.push(rest.slice(0, cut));
13
+ rest = rest.slice(cut).replace(/^\n/, "");
14
+ }
15
+ if (rest) chunks.push(rest);
16
+ return chunks;
17
+ }
18
+
19
+ export class TelegramApi {
20
+ constructor(token, fetchImpl = globalThis.fetch) {
21
+ if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required");
22
+ if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable");
23
+ this.base = `https://api.telegram.org/bot${token}`;
24
+ this.fetch = fetchImpl;
25
+ }
26
+
27
+ async call(method, body = {}, signal) {
28
+ const response = await this.fetch(`${this.base}/${method}`, {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json" },
31
+ body: JSON.stringify(body),
32
+ signal: signal || AbortSignal.timeout(15000),
33
+ });
34
+ const json = await response.json();
35
+ if (!response.ok || !json.ok) {
36
+ throw new Error(`Telegram ${method} failed: ${json.description || response.status}`);
37
+ }
38
+ return json.result;
39
+ }
40
+
41
+ getUpdates(offset, timeoutSeconds, signal) {
42
+ return this.call("getUpdates", {
43
+ offset,
44
+ timeout: timeoutSeconds,
45
+ allowed_updates: ["message", "callback_query"],
46
+ }, signal);
47
+ }
48
+
49
+ async sendMessage(chatId, text, extra = {}) {
50
+ for (const chunk of splitTelegramText(text)) {
51
+ await this.call("sendMessage", {
52
+ chat_id: chatId,
53
+ text: chunk || " ",
54
+ disable_web_page_preview: true,
55
+ ...extra,
56
+ });
57
+ }
58
+ }
59
+
60
+ answerCallbackQuery(id, text) {
61
+ return this.call("answerCallbackQuery", { callback_query_id: id, text, show_alert: false });
62
+ }
63
+ }
package/src/user-id.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { loadTelegramEnv } from "./env.js";
3
+ import { TelegramApi } from "./telegram.js";
4
+
5
+ loadTelegramEnv(process.cwd());
6
+ try {
7
+ const api = new TelegramApi(process.env.TELEGRAM_BOT_TOKEN);
8
+ const updates = await api.getUpdates(undefined, 0, AbortSignal.timeout(10000));
9
+ const users = new Map();
10
+ for (const update of updates) {
11
+ const user = update.message?.from;
12
+ if (user && !user.is_bot) users.set(user.id, user.username || user.first_name || "Telegram user");
13
+ }
14
+ for (const [id, name] of users) console.log(`${name}: TELEGRAM_USER=${id}`);
15
+ if (!users.size) console.log("Send a private message such as Hallo to your bot, then run this command again. Keep the OpenCode Telegram bridge disconnected during setup.");
16
+ } catch {
17
+ console.error("Could not read Telegram updates. Check telegram.env, your network, and that no other instance polls this bot. The token is not printed.");
18
+ process.exitCode = 1;
19
+ }