-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpair.ts
More file actions
executable file
·184 lines (162 loc) · 5.61 KB
/
Copy pathpair.ts
File metadata and controls
executable file
·184 lines (162 loc) · 5.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
#!/usr/bin/env bun
/**
* Pairing CLI for the telegram-opencode plugin.
*
* Usage:
* bun /path/to/telegram-opencode/pair.ts [--token=<bot-token>] [--timeout=60]
*
* Without args:
* - Reads TELEGRAM_BOT_TOKEN from ~/.opencode/channels/telegram/.env
* - Looks up the bot's @username via getMe
* - Prints "DM @<username> now to pair"
* - Polls Telegram for the first inbound DM
* - Appends the sender's user_id to access.json.allowFrom (dedupe)
* - Sends a "✅ paired" reply, exits
*
* Pattern: CLI-initiated (server can't be a coordination point because the
* opencode MCP server lazy-spawns — running this while opencode isn't is the
* common case during first-time setup).
*
* Refuses to run if the MCP server is already polling (PID_FILE present and
* alive) — two consumers on one getUpdates token conflict (409). The user
* must stop their opencode session first, pair, then restart opencode.
*/
import { Bot, GrammyError } from 'grammy'
import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
function arg(name: string, fallback?: string): string | undefined {
for (const a of process.argv.slice(2)) {
if (a === `--${name}`) return ''
if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3)
}
return fallback
}
const STATE_DIR = arg('state-dir')
?? process.env.TELEGRAM_STATE_DIR
?? join(process.env.OPENCODE_HOME ?? join(homedir(), '.opencode'), 'channels', 'telegram')
const ACCESS_FILE = join(STATE_DIR, 'access.json')
const ENV_FILE = join(STATE_DIR, '.env')
const PID_FILE = join(STATE_DIR, 'bot.pid')
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
try {
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
}
} catch {}
const TOKEN = arg('token') ?? process.env.TELEGRAM_BOT_TOKEN
if (!TOKEN) {
console.error(
`telegram-opencode pair: bot token required.\n` +
` set TELEGRAM_BOT_TOKEN in ${ENV_FILE}, or pass --token=<...>`,
)
process.exit(1)
}
// 409 Conflict guard: Telegram allows one getUpdates consumer per token.
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
if (pid > 1) {
process.kill(pid, 0)
console.error(
`telegram-opencode pair: another poller is already running (pid ${pid}).\n` +
` stop your opencode session first, then re-run this command.`,
)
process.exit(2)
}
} catch {}
const timeoutSec = Math.max(10, Math.min(300, Number(arg('timeout') ?? 60)))
type AccessJson = {
allowFrom: string[]
groups: Record<string, { requireMention: boolean; allowFrom: string[] }>
}
function loadAccess(): AccessJson {
try {
const parsed = JSON.parse(readFileSync(ACCESS_FILE, 'utf8')) as Partial<AccessJson>
return {
allowFrom: parsed.allowFrom ?? [],
groups: parsed.groups ?? {},
}
} catch {
return { allowFrom: [], groups: {} }
}
}
function saveAccess(a: AccessJson) {
const tmp = ACCESS_FILE + '.tmp'
writeFileSync(tmp, JSON.stringify(a, null, 2))
chmodSync(tmp, 0o600)
// rename is atomic on POSIX — readers never see a half-written file
renameSync(tmp, ACCESS_FILE)
}
const bot = new Bot(TOKEN)
let me: { username?: string; id?: number }
try {
me = await bot.api.getMe()
} catch (err) {
console.error(`telegram-opencode pair: getMe failed — bad token?\n ${err}`)
process.exit(3)
}
console.log(`bot: @${me.username} (id ${me.id})`)
console.log(`DM @${me.username} from your Telegram account within ${timeoutSec}s to pair...`)
const paired = await new Promise<{ user_id: string; chat_id: string; first_name?: string } | null>(resolve => {
let done = false
const timer = setTimeout(() => {
if (done) return
done = true
bot.stop().catch(() => {})
resolve(null)
}, timeoutSec * 1000)
bot.on('message', async ctx => {
if (done) return
if (!ctx.from || !ctx.chat) return
if (ctx.chat.type !== 'private') return // pair via DM only — never from groups
done = true
clearTimeout(timer)
const user_id = String(ctx.from.id)
const chat_id = String(ctx.chat.id)
resolve({ user_id, chat_id, first_name: ctx.from.first_name })
try {
await ctx.reply(`✅ paired — your user_id ${user_id} is now on the allowlist.`)
} catch {}
await bot.stop().catch(() => {})
})
process.on('SIGINT', () => {
if (done) return
done = true
clearTimeout(timer)
bot.stop().catch(() => {})
resolve(null)
})
void bot.start({ drop_pending_updates: true }).catch(err => {
if (done) return
done = true
clearTimeout(timer)
const is409 = err instanceof GrammyError && err.error_code === 409
if (is409) {
console.error(
`telegram-opencode pair: 409 Conflict — another bot poller is using this token.\n` +
` stop your opencode session (or any other process polling this token), then retry.`,
)
} else {
console.error(`telegram-opencode pair: bot.start failed: ${err}`)
}
resolve(null)
})
})
if (!paired) {
console.error(`\ntimeout — no DM received in ${timeoutSec}s. nothing changed.`)
process.exit(4)
}
const access = loadAccess()
if (access.allowFrom.includes(paired.user_id)) {
console.log(`already paired — ${paired.user_id} was on the allowlist. nothing changed.`)
process.exit(0)
}
access.allowFrom.push(paired.user_id)
saveAccess(access)
console.log(
`\n✅ paired:\n` +
` user_id: ${paired.user_id}${paired.first_name ? ` (${paired.first_name})` : ''}\n` +
` chat_id: ${paired.chat_id}\n` +
` access.json updated: ${ACCESS_FILE}`,
)