agents_control 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +282 -0
- data/exe/agents_control +6 -0
- data/lib/agents_control/agents/base.rb +52 -0
- data/lib/agents_control/agents/claude_code.rb +243 -0
- data/lib/agents_control/anchors/scheduler.rb +161 -0
- data/lib/agents_control/channels/base.rb +27 -0
- data/lib/agents_control/channels/telegram/api.rb +179 -0
- data/lib/agents_control/channels/telegram/bot.rb +146 -0
- data/lib/agents_control/channels/telegram/channel.rb +251 -0
- data/lib/agents_control/channels/telegram/chunker.rb +64 -0
- data/lib/agents_control/channels/telegram/keyboards.rb +129 -0
- data/lib/agents_control/channels/telegram/markdown.rb +59 -0
- data/lib/agents_control/channels/telegram/router.rb +482 -0
- data/lib/agents_control/channels/telegram/settings_menu.rb +112 -0
- data/lib/agents_control/cli.rb +347 -0
- data/lib/agents_control/config.rb +178 -0
- data/lib/agents_control/console.rb +320 -0
- data/lib/agents_control/daemon.rb +251 -0
- data/lib/agents_control/dispatcher.rb +163 -0
- data/lib/agents_control/doctor.rb +234 -0
- data/lib/agents_control/event.rb +111 -0
- data/lib/agents_control/executor.rb +83 -0
- data/lib/agents_control/hooks/server.rb +197 -0
- data/lib/agents_control/keyboard.rb +90 -0
- data/lib/agents_control/menu.rb +100 -0
- data/lib/agents_control/pending.rb +70 -0
- data/lib/agents_control/process_probe.rb +136 -0
- data/lib/agents_control/prompt.rb +227 -0
- data/lib/agents_control/rate_limit_watcher.rb +202 -0
- data/lib/agents_control/registry.rb +115 -0
- data/lib/agents_control/reply.rb +41 -0
- data/lib/agents_control/screen_watcher.rb +158 -0
- data/lib/agents_control/secrets.rb +256 -0
- data/lib/agents_control/service.rb +165 -0
- data/lib/agents_control/session.rb +66 -0
- data/lib/agents_control/store.rb +132 -0
- data/lib/agents_control/terminals/base.rb +76 -0
- data/lib/agents_control/terminals/iterm2.rb +167 -0
- data/lib/agents_control/terminals/null.rb +27 -0
- data/lib/agents_control/terminals/tmux.rb +106 -0
- data/lib/agents_control/transcript.rb +123 -0
- data/lib/agents_control/version.rb +5 -0
- data/lib/agents_control/which.rb +59 -0
- data/lib/agents_control.rb +52 -0
- metadata +102 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module AgentsControl
|
|
7
|
+
module Agents
|
|
8
|
+
# Claude Code.
|
|
9
|
+
#
|
|
10
|
+
# `{"decision":"block","reason":"..."}` returned to a Stop hook comes
|
|
11
|
+
# back to the agent as user input, and the agent continues working
|
|
12
|
+
# with that text — this is what the whole idea of answering from
|
|
13
|
+
# Telegram rests on.
|
|
14
|
+
class ClaudeCode < Base
|
|
15
|
+
# Claude Code's config directory honors CLAUDE_CONFIG_DIR — just
|
|
16
|
+
# like the agent itself. This matters both for anyone keeping
|
|
17
|
+
# settings outside the home directory, and for tests: without this
|
|
18
|
+
# override a test run would edit the user's real file.
|
|
19
|
+
def self.settings_path
|
|
20
|
+
base = ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude")
|
|
21
|
+
|
|
22
|
+
File.join(base, "settings.json")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The events we care about. Others arrive too but are ignored:
|
|
26
|
+
# subscribing to everything is extra overhead on every agent call.
|
|
27
|
+
EVENTS = %w[SessionStart SessionEnd PermissionRequest Stop Notification StopFailure].freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def key = :claude_code
|
|
31
|
+
|
|
32
|
+
def binaries = %w[claude]
|
|
33
|
+
|
|
34
|
+
def handles?(payload)
|
|
35
|
+
payload.is_a?(Hash) && payload.key?("hook_event_name")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(settings_path: self.class.settings_path)
|
|
40
|
+
@settings_path = settings_path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def capabilities = %i[push blocking_reply]
|
|
44
|
+
|
|
45
|
+
def to_event(payload)
|
|
46
|
+
case payload["hook_event_name"]
|
|
47
|
+
when "SessionStart" then build(payload, :started)
|
|
48
|
+
when "SessionEnd" then build(payload, :ended)
|
|
49
|
+
when "PermissionRequest" then permission_event(payload)
|
|
50
|
+
when "Stop" then stop_event(payload)
|
|
51
|
+
when "Notification" then notification_event(payload)
|
|
52
|
+
when "StopFailure" then build(payload, :error, text: payload["error"])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reply → the hook response body. The shape depends on the event:
|
|
57
|
+
# granting permission and continuing a dialog are structured
|
|
58
|
+
# differently in the API.
|
|
59
|
+
def to_response(event, reply)
|
|
60
|
+
case event.raw["hook_event_name"]
|
|
61
|
+
when "PermissionRequest" then permission_response(event, reply)
|
|
62
|
+
when "Stop" then stop_response(reply)
|
|
63
|
+
else {}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ── installation ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
# Merge, not overwrite: settings.json holds other settings we don't
|
|
70
|
+
# own, and they must not be clobbered. The timeout is set
|
|
71
|
+
# explicitly: Claude Code waits longer than the documented 600
|
|
72
|
+
# seconds, and how long to wait is our call.
|
|
73
|
+
def install!(url, secret: nil, timeout: 660)
|
|
74
|
+
settings = read_settings
|
|
75
|
+
settings["hooks"] ||= {}
|
|
76
|
+
|
|
77
|
+
EVENTS.each do |name|
|
|
78
|
+
settings["hooks"][name] = merge_hook(settings["hooks"][name],
|
|
79
|
+
hook_entry(url, name, secret, timeout))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
write_settings(settings)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def uninstall!
|
|
86
|
+
settings = read_settings
|
|
87
|
+
hooks = settings["hooks"] || {}
|
|
88
|
+
|
|
89
|
+
EVENTS.each do |name|
|
|
90
|
+
next unless hooks[name]
|
|
91
|
+
|
|
92
|
+
hooks[name] = hooks[name].filter_map { |group| without_ours(group) }
|
|
93
|
+
hooks.delete(name) if hooks[name].empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# If the key wasn't there before us, it shouldn't be there after
|
|
97
|
+
# us either: cleaning up after ourselves means not leaving an
|
|
98
|
+
# empty shell behind.
|
|
99
|
+
settings.delete("hooks") if hooks.empty?
|
|
100
|
+
|
|
101
|
+
write_settings(settings)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def installed?
|
|
105
|
+
hooks = read_settings["hooks"] || {}
|
|
106
|
+
|
|
107
|
+
EVENTS.all? { |name| Array(hooks[name]).any? { |group| ours?(group) } }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def build(payload, kind, text: nil, **extra)
|
|
113
|
+
Event.new(
|
|
114
|
+
kind: kind,
|
|
115
|
+
agent: key,
|
|
116
|
+
session_id: payload["session_id"],
|
|
117
|
+
cwd: payload["cwd"],
|
|
118
|
+
transcript_path: payload["transcript_path"],
|
|
119
|
+
text: text,
|
|
120
|
+
raw: payload,
|
|
121
|
+
**extra
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def permission_event(payload)
|
|
126
|
+
build(payload, :needs_permission,
|
|
127
|
+
tool_name: payload["tool_name"],
|
|
128
|
+
tool_input: payload["tool_input"])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Stop fires at the end of every turn, not only when the agent is
|
|
132
|
+
# actually asking something. The payload alone can't tell them
|
|
133
|
+
# apart — whether to bother the human is a decision the Dispatcher
|
|
134
|
+
# makes based on settings.
|
|
135
|
+
#
|
|
136
|
+
# stop_hook_active means we're already holding this turn blocked.
|
|
137
|
+
# Answering with another block would produce an infinite loop.
|
|
138
|
+
def stop_event(payload)
|
|
139
|
+
return nil if payload["stop_hook_active"]
|
|
140
|
+
|
|
141
|
+
build(payload, :needs_input, text: payload["last_assistant_message"])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def notification_event(payload)
|
|
145
|
+
kind = payload["notificationType"] == "agent_completed" ? :finished : :progress
|
|
146
|
+
|
|
147
|
+
build(payload, kind, text: payload["message"])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def permission_response(event, reply)
|
|
151
|
+
behavior = reply.permits? ? "allow" : "deny"
|
|
152
|
+
decision = { "behavior" => behavior }
|
|
153
|
+
|
|
154
|
+
# "Allow and don't ask again" — the rule is remembered by the
|
|
155
|
+
# agent itself, we don't need to store it afterward.
|
|
156
|
+
decision["storeRule"] = { "matcher" => event.tool_name } if reply.allow? && reply.remember
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
"hookSpecificOutput" => {
|
|
160
|
+
"hookEventName" => "PermissionRequest",
|
|
161
|
+
"decision" => decision
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# An empty reply means "let the agent stop". A block with text
|
|
167
|
+
# feeds that text back to the agent as input.
|
|
168
|
+
def stop_response(reply)
|
|
169
|
+
return {} unless reply.text? && !reply.text.to_s.empty?
|
|
170
|
+
|
|
171
|
+
{ "decision" => "block", "reason" => reply.text }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ── settings.json handling ─────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
# The secret is written as a literal value, not via $VAR:
|
|
177
|
+
# environment substitution pulls variables from the agent's own
|
|
178
|
+
# process, which the user launches by hand — our environment isn't
|
|
179
|
+
# there.
|
|
180
|
+
#
|
|
181
|
+
# The settings file is already readable only by its owner, and the
|
|
182
|
+
# secret protects against another process on the local port, not
|
|
183
|
+
# against another user on the same account.
|
|
184
|
+
def hook_entry(url, name, secret, timeout)
|
|
185
|
+
hook = {
|
|
186
|
+
"type" => "http",
|
|
187
|
+
"url" => "#{url}/#{name}",
|
|
188
|
+
"timeout" => timeout,
|
|
189
|
+
"statusMessage" => "agents_control: waiting for a reply in Telegram",
|
|
190
|
+
# This marker is how our own entries are found on uninstall,
|
|
191
|
+
# without touching anyone else's.
|
|
192
|
+
"_agents_control" => true
|
|
193
|
+
}
|
|
194
|
+
hook["headers"] = { "Authorization" => "Bearer #{secret}" } if secret
|
|
195
|
+
|
|
196
|
+
{ "hooks" => [hook] }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def merge_hook(existing, entry)
|
|
200
|
+
groups = Array(existing).reject { |group| ours?(group) }
|
|
201
|
+
|
|
202
|
+
groups + [entry]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ours?(group)
|
|
206
|
+
Array(group["hooks"]).any? { |hook| hook["_agents_control"] }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def without_ours(group)
|
|
210
|
+
remaining = Array(group["hooks"]).reject { |hook| hook["_agents_control"] }
|
|
211
|
+
|
|
212
|
+
remaining.empty? ? nil : group.merge("hooks" => remaining)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def read_settings
|
|
216
|
+
return {} unless File.exist?(@settings_path)
|
|
217
|
+
|
|
218
|
+
parsed = JSON.parse(File.read(@settings_path))
|
|
219
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
220
|
+
rescue JSON::ParserError
|
|
221
|
+
# A broken file that isn't ours to fix, but we definitely have
|
|
222
|
+
# no right to overwrite its contents either.
|
|
223
|
+
raise Error, "could not parse #{@settings_path}: file is corrupted"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Via a temporary file: an interrupted write must not leave the
|
|
227
|
+
# user with no Claude Code settings at all. Chmod'd before the
|
|
228
|
+
# rename: this file carries our hook secret in plaintext once
|
|
229
|
+
# install! runs, and it must never sit world-readable, even briefly.
|
|
230
|
+
def write_settings(settings)
|
|
231
|
+
FileUtils.mkdir_p(File.dirname(@settings_path))
|
|
232
|
+
temporary = "#{@settings_path}.agents_control.tmp"
|
|
233
|
+
|
|
234
|
+
File.write(temporary, JSON.pretty_generate(settings))
|
|
235
|
+
File.chmod(0o600, temporary)
|
|
236
|
+
File.rename(temporary, @settings_path)
|
|
237
|
+
true
|
|
238
|
+
ensure
|
|
239
|
+
FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module AgentsControl
|
|
6
|
+
module Anchors
|
|
7
|
+
# Placing rate-limit windows on a schedule.
|
|
8
|
+
#
|
|
9
|
+
# A five-hour window starts at the minute of the first message and
|
|
10
|
+
# expires exactly three hundred minutes later. This tool doesn't add
|
|
11
|
+
# a single extra token — it moves window boundaries to where they're
|
|
12
|
+
# convenient. The difference is between "the window reset at 2:37pm,
|
|
13
|
+
# mid-work" and "windows at exactly 7am, noon, 5pm."
|
|
14
|
+
#
|
|
15
|
+
# Pinging has to use a cheap model. The five-hour window is shared
|
|
16
|
+
# across the account, but weekly limits are tracked per model family:
|
|
17
|
+
# an anchor on opus would spend the scarcest bucket for an effect
|
|
18
|
+
# haiku gives for free.
|
|
19
|
+
class Scheduler
|
|
20
|
+
DAYS = %w[sun mon tue wed thu fri sat].freeze
|
|
21
|
+
|
|
22
|
+
# How late it's still worth catching up on a missed slot. If the
|
|
23
|
+
# laptop slept and woke an hour later, an anchor is already
|
|
24
|
+
# pointless — the window will start somewhere other than planned regardless.
|
|
25
|
+
GRACE = 300
|
|
26
|
+
|
|
27
|
+
# How often to wake up and check the clock.
|
|
28
|
+
TICK = 30
|
|
29
|
+
|
|
30
|
+
def initialize(config:, store:, executor: Executor.new, clock: -> { Time.now }, logger: nil)
|
|
31
|
+
@config = config
|
|
32
|
+
@store = store
|
|
33
|
+
@executor = executor
|
|
34
|
+
@clock = clock
|
|
35
|
+
@logger = logger
|
|
36
|
+
@running = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The thread always starts; whether it's enabled is checked on
|
|
40
|
+
# every tick — so a setting changed from the menu takes effect
|
|
41
|
+
# without restarting the daemon.
|
|
42
|
+
def start
|
|
43
|
+
@running = true
|
|
44
|
+
@thread = Thread.new do
|
|
45
|
+
tick while @running
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stop
|
|
52
|
+
@running = false
|
|
53
|
+
@thread&.kill
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One pass: fire if the time has come.
|
|
57
|
+
def tick(now = @clock.call)
|
|
58
|
+
due_slot(now)&.then { |slot| fire(slot, now) } if enabled?
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
log("failure: #{e.class}: #{e.message}")
|
|
61
|
+
ensure
|
|
62
|
+
sleep(TICK) if @running
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The next firing time — for doctor and /status.
|
|
66
|
+
def next_run_at(from = @clock.call)
|
|
67
|
+
(0..7).each do |offset|
|
|
68
|
+
date = from.to_date + offset
|
|
69
|
+
next unless enabled_day?(date)
|
|
70
|
+
|
|
71
|
+
slot = slots_on(date).find { |time| time > from }
|
|
72
|
+
return slot if slot
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Whether a window is currently active. Known because the daemon
|
|
79
|
+
# sees every agent event: any of them means a human was just working.
|
|
80
|
+
def window_active?(now = @clock.call)
|
|
81
|
+
last = @store.get(ACTIVITY_KEY)
|
|
82
|
+
return false unless last
|
|
83
|
+
|
|
84
|
+
now.to_i - last.to_i < WINDOW
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ACTIVITY_KEY = "agent:last_activity"
|
|
88
|
+
WINDOW = 5 * 3600
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def enabled? = @config.get("anchors.enabled", false)
|
|
93
|
+
|
|
94
|
+
def schedule = Array(@config.get("anchors.schedule", []))
|
|
95
|
+
|
|
96
|
+
def enabled_day?(date)
|
|
97
|
+
days = Array(@config.get("anchors.days", DAYS)).map(&:to_s)
|
|
98
|
+
|
|
99
|
+
days.include?(DAYS[date.wday])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def slots_on(date)
|
|
103
|
+
schedule.filter_map do |entry|
|
|
104
|
+
hour, minute = entry.to_s.split(":").map(&:to_i)
|
|
105
|
+
next if hour.nil?
|
|
106
|
+
|
|
107
|
+
Time.new(date.year, date.month, date.day, hour, minute, 0)
|
|
108
|
+
end.sort
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def due_slot(now)
|
|
112
|
+
return nil unless enabled_day?(now.to_date)
|
|
113
|
+
|
|
114
|
+
slots_on(now.to_date).find do |slot|
|
|
115
|
+
now >= slot && now - slot < GRACE && !fired?(slot)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fired?(slot) = !@store.get(slot_key(slot)).nil?
|
|
120
|
+
|
|
121
|
+
def slot_key(slot) = "anchor:#{slot.strftime('%Y-%m-%d %H:%M')}"
|
|
122
|
+
|
|
123
|
+
def fire(slot, now)
|
|
124
|
+
# The marker is set before the call, not after: if the ping
|
|
125
|
+
# hangs or the process crashes, retrying it half a minute later
|
|
126
|
+
# is pointless.
|
|
127
|
+
@store.put(now.to_i, ttl: 86_400 * 2, key: slot_key(slot))
|
|
128
|
+
|
|
129
|
+
if @config.get("anchors.skip_if_window_active", true) && window_active?(now)
|
|
130
|
+
return log("skipping #{slot.strftime('%H:%M')}: window is already open")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
ping(slot)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ping(slot)
|
|
137
|
+
binary = Which.find("claude")
|
|
138
|
+
return log("couldn't find claude — anchor skipped") unless binary
|
|
139
|
+
|
|
140
|
+
model = @config.get("anchors.model", "haiku")
|
|
141
|
+
result = @executor.run(binary, "-p", "ok", "--model", model,
|
|
142
|
+
"--max-turns", "1", timeout: 120)
|
|
143
|
+
|
|
144
|
+
if result.success?
|
|
145
|
+
log("anchor #{slot.strftime('%H:%M')} on #{model}: window opened")
|
|
146
|
+
else
|
|
147
|
+
log("anchor #{slot.strftime('%H:%M')} failed: #{result.stderr.to_s[0, 120]}")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# log() catches any write failure itself: an exception raised
|
|
152
|
+
# inside a rescue isn't caught by that same rescue, and would kill
|
|
153
|
+
# the thread for good.
|
|
154
|
+
def log(message)
|
|
155
|
+
@logger&.puts("[anchors] #{message}")
|
|
156
|
+
rescue StandardError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentsControl
|
|
4
|
+
module Channels
|
|
5
|
+
# The notification channel contract.
|
|
6
|
+
#
|
|
7
|
+
# There's only one implementation right now — Telegram. The interface
|
|
8
|
+
# is still broken out because the core must not know about buttons
|
|
9
|
+
# and chats: it operates on an event and a reply, and how that's
|
|
10
|
+
# shown to a human is the channel's concern.
|
|
11
|
+
#
|
|
12
|
+
# A second implementation (ntfy, Slack, whatever) is deliberately not
|
|
13
|
+
# invented ahead of time: the seam is marked out, but guessing its
|
|
14
|
+
# shape before a real need shows up is exactly the kind of
|
|
15
|
+
# complication this project avoids.
|
|
16
|
+
class Base
|
|
17
|
+
# Notify, expecting nothing back.
|
|
18
|
+
def notify(_event) = raise(NotImplementedError)
|
|
19
|
+
|
|
20
|
+
# Ask a question and return a Reply, or nil once the timeout expires.
|
|
21
|
+
def ask(_event, timeout:) = raise(NotImplementedError)
|
|
22
|
+
|
|
23
|
+
# Whether the channel is ready to work: has a token, has someone to talk to.
|
|
24
|
+
def ready? = raise(NotImplementedError)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "json"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module AgentsControl
|
|
10
|
+
module Channels
|
|
11
|
+
module Telegram
|
|
12
|
+
# A thin Bot API client built on the stdlib.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately gem-free: `net/http` covers everything needed, and
|
|
15
|
+
# an extra dependency in a tool installed with a single command
|
|
16
|
+
# costs more than the lines it would save.
|
|
17
|
+
#
|
|
18
|
+
# The token is part of the URL, so any trace of a request —
|
|
19
|
+
# exception message, log, debug output — must go through redact.
|
|
20
|
+
# Otherwise the secret leaks somewhere nobody meant to put it.
|
|
21
|
+
class Api
|
|
22
|
+
HOST = "api.telegram.org"
|
|
23
|
+
|
|
24
|
+
# Telegram requires answering a button press within 10 seconds,
|
|
25
|
+
# or it stays stuck in the interface. Regular calls are kept
|
|
26
|
+
# noticeably shorter than that.
|
|
27
|
+
CALL_TIMEOUT = 8
|
|
28
|
+
|
|
29
|
+
Error = Class.new(AgentsControl::Error)
|
|
30
|
+
|
|
31
|
+
# Another process is already reading updates with this same
|
|
32
|
+
# token. This doesn't resolve itself: one instance per token is required.
|
|
33
|
+
Conflict = Class.new(Error)
|
|
34
|
+
|
|
35
|
+
# Too many requests. The response carries retry_after — how long to wait.
|
|
36
|
+
class TooManyRequests < Error
|
|
37
|
+
attr_reader :retry_after
|
|
38
|
+
|
|
39
|
+
def initialize(message, retry_after)
|
|
40
|
+
super(message)
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The network dropped. Unlike other errors, this is an expected
|
|
46
|
+
# state for a laptop that got closed and carried off; it's fixed
|
|
47
|
+
# by retrying, not by stopping.
|
|
48
|
+
Unavailable = Class.new(Error)
|
|
49
|
+
|
|
50
|
+
def initialize(token, http: nil)
|
|
51
|
+
@token = token
|
|
52
|
+
@http = http || Http.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_me = call("getMe")
|
|
56
|
+
|
|
57
|
+
# timeout here means long polling: the connection stays open
|
|
58
|
+
# until an update shows up. The network timeout has to be
|
|
59
|
+
# noticeably longer, or the client would drop the connection
|
|
60
|
+
# right as the server was about to answer.
|
|
61
|
+
def get_updates(offset: nil, timeout: 30)
|
|
62
|
+
call(
|
|
63
|
+
"getUpdates",
|
|
64
|
+
{ offset: offset, timeout: timeout, allowed_updates: %w[message callback_query] },
|
|
65
|
+
read_timeout: timeout + 15
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def send_message(chat_id:, text:, reply_markup: nil, parse_mode: nil)
|
|
70
|
+
call("sendMessage", {
|
|
71
|
+
chat_id: chat_id, text: text, parse_mode: parse_mode,
|
|
72
|
+
reply_markup: reply_markup && JSON.generate(reply_markup)
|
|
73
|
+
})
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def edit_message_text(chat_id:, message_id:, text:, reply_markup: nil)
|
|
77
|
+
call("editMessageText", {
|
|
78
|
+
chat_id: chat_id, message_id: message_id, text: text,
|
|
79
|
+
reply_markup: reply_markup && JSON.generate(reply_markup)
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The command menu in the Telegram UI: a button next to the
|
|
84
|
+
# input field instead of hunting through the chat for a list.
|
|
85
|
+
def set_my_commands(commands)
|
|
86
|
+
payload = commands.map { |name, description| { command: name, description: description } }
|
|
87
|
+
|
|
88
|
+
call("setMyCommands", { commands: JSON.generate(payload) })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def answer_callback_query(id, text: nil, show_alert: false)
|
|
92
|
+
call("answerCallbackQuery", { callback_query_id: id, text: text, show_alert: show_alert })
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Strip the token out of arbitrary text before logging it.
|
|
96
|
+
#
|
|
97
|
+
# Matches both the literal value and its `inspect`-escaped form
|
|
98
|
+
# (control characters like a tab become the two characters
|
|
99
|
+
# `\t`): `URI::InvalidURIError`'s message is built with
|
|
100
|
+
# `inspect`, so a plain literal match alone would miss the token
|
|
101
|
+
# whenever it contains anything `inspect` escapes, and let it
|
|
102
|
+
# straight through into a log.
|
|
103
|
+
#
|
|
104
|
+
# An empty token is guarded separately: `gsub` against an empty
|
|
105
|
+
# pattern matches between every character, turning any message
|
|
106
|
+
# into unreadable noise instead of leaving it untouched.
|
|
107
|
+
def redact(text)
|
|
108
|
+
return text.to_s if @token.to_s.empty?
|
|
109
|
+
|
|
110
|
+
text.to_s.gsub(Regexp.union(@token.to_s, escaped_token), "<token>")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def escaped_token = @token.to_s.inspect[1..-2]
|
|
116
|
+
|
|
117
|
+
def call(method, params = {}, read_timeout: CALL_TIMEOUT)
|
|
118
|
+
response = @http.post(url_for(method), compact(params), read_timeout: read_timeout)
|
|
119
|
+
parse(response, method)
|
|
120
|
+
rescue Http::NetworkError => e
|
|
121
|
+
raise Unavailable, redact(e.message)
|
|
122
|
+
rescue URI::InvalidURIError
|
|
123
|
+
# Don't even try to redact this one: the token sits inside a
|
|
124
|
+
# URI, escaped, and a redaction pass that turned out wrong
|
|
125
|
+
# would be worse than a message with none of the URI in it at all.
|
|
126
|
+
raise Error, "#{method}: token is not valid for a URL (unexpected characters)"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def url_for(method) = URI("https://#{HOST}/bot#{@token}/#{method}")
|
|
130
|
+
|
|
131
|
+
# Drop nil fields: Telegram treats them as values that were actually passed.
|
|
132
|
+
def compact(params) = params.compact
|
|
133
|
+
|
|
134
|
+
def parse(response, method)
|
|
135
|
+
body = JSON.parse(response.body)
|
|
136
|
+
return body["result"] if body["ok"]
|
|
137
|
+
|
|
138
|
+
raise_api_error(body, method)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
raise Error, "#{method}: unrecognized response (HTTP #{response.code})"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def raise_api_error(body, method)
|
|
144
|
+
description = redact(body["description"].to_s)
|
|
145
|
+
retry_after = body.dig("parameters", "retry_after")
|
|
146
|
+
|
|
147
|
+
case body["error_code"]
|
|
148
|
+
when 409 then raise Conflict, "#{method}: another process is already reading updates"
|
|
149
|
+
when 429 then raise TooManyRequests.new("#{method}: too many requests", retry_after.to_i)
|
|
150
|
+
else raise Error, "#{method}: #{description}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Transport is split out separately so tests never hit the network.
|
|
156
|
+
class Http
|
|
157
|
+
NetworkError = Class.new(StandardError)
|
|
158
|
+
|
|
159
|
+
def post(uri, params, read_timeout:)
|
|
160
|
+
request = Net::HTTP::Post.new(uri)
|
|
161
|
+
request.set_form_data(params)
|
|
162
|
+
|
|
163
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
164
|
+
use_ssl: true, open_timeout: 10, read_timeout: read_timeout) do |http|
|
|
165
|
+
http.request(request)
|
|
166
|
+
end
|
|
167
|
+
rescue *NETWORK_ERRORS => e
|
|
168
|
+
raise NetworkError, e.message
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
NETWORK_ERRORS = [
|
|
172
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
|
|
173
|
+
Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
|
|
174
|
+
SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
|
|
175
|
+
].freeze
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|