kward 0.79.0 → 0.80.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 +4 -4
- data/.github/workflows/ci.yml +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Kward
|
|
6
|
+
module Telegram
|
|
7
|
+
# Small dependency-free client for the Telegram Bot API.
|
|
8
|
+
class BotApi
|
|
9
|
+
API_BASE_URL = "https://api.telegram.org".freeze
|
|
10
|
+
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :method, :error_code, :parameters
|
|
14
|
+
|
|
15
|
+
def initialize(method, description, error_code: nil, parameters: nil)
|
|
16
|
+
@method = method
|
|
17
|
+
@error_code = error_code
|
|
18
|
+
@parameters = parameters
|
|
19
|
+
super("Telegram #{method} failed: #{description.to_s}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class RateLimitError < Error
|
|
24
|
+
attr_reader :retry_after
|
|
25
|
+
|
|
26
|
+
def initialize(method, description, retry_after:, error_code: nil, parameters: nil)
|
|
27
|
+
@retry_after = Integer(retry_after)
|
|
28
|
+
super(method, description, error_code: error_code, parameters: parameters)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(token:, requester: nil, base_url: API_BASE_URL)
|
|
33
|
+
@token = token.to_s
|
|
34
|
+
raise ArgumentError, "Telegram bot token is required" if @token.empty?
|
|
35
|
+
|
|
36
|
+
@requester = requester || method(:request)
|
|
37
|
+
@base_url = base_url.to_s.sub(%r{/\z}, "")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_me
|
|
41
|
+
call("getMe")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete_webhook(drop_pending_updates: false)
|
|
45
|
+
call("deleteWebhook", "drop_pending_updates" => !!drop_pending_updates)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_updates(offset: nil, timeout: 25)
|
|
49
|
+
timeout = Integer(timeout)
|
|
50
|
+
raise ArgumentError, "Telegram polling timeout must be between 0 and 50 seconds" unless (0..50).cover?(timeout)
|
|
51
|
+
|
|
52
|
+
params = {
|
|
53
|
+
"timeout" => timeout,
|
|
54
|
+
"allowed_updates" => JSON.generate(%w[message callback_query])
|
|
55
|
+
}
|
|
56
|
+
params["offset"] = Integer(offset) unless offset.nil?
|
|
57
|
+
Array(call("getUpdates", params))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def answer_callback_query(callback_query_id:, text: nil, show_alert: false)
|
|
61
|
+
params = {
|
|
62
|
+
"callback_query_id" => callback_query_id.to_s,
|
|
63
|
+
"show_alert" => !!show_alert
|
|
64
|
+
}
|
|
65
|
+
params["text"] = text.to_s unless text.nil?
|
|
66
|
+
call("answerCallbackQuery", params)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def send_message(chat_id:, text:, reply_to_message_id: nil, reply_markup: nil)
|
|
70
|
+
text = text.to_s
|
|
71
|
+
raise ArgumentError, "Telegram message text is required" if text.empty?
|
|
72
|
+
|
|
73
|
+
params = { "chat_id" => Integer(chat_id), "text" => text }
|
|
74
|
+
unless reply_to_message_id.nil?
|
|
75
|
+
params["reply_parameters"] = JSON.generate({ "message_id" => Integer(reply_to_message_id) })
|
|
76
|
+
end
|
|
77
|
+
params["reply_markup"] = JSON.generate(reply_markup) unless reply_markup.nil?
|
|
78
|
+
call("sendMessage", params)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def call(method, params = {})
|
|
84
|
+
response = @requester.call(method, params)
|
|
85
|
+
unless response.is_a?(Hash) && response["ok"]
|
|
86
|
+
raise_error(method, response || {})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
response["result"]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def raise_error(method, response)
|
|
93
|
+
description = (response["description"] || "unknown API error").to_s.gsub(@token, "[REDACTED]")
|
|
94
|
+
parameters = response["parameters"] || {}
|
|
95
|
+
if response["error_code"].to_i == 429 && parameters["retry_after"]
|
|
96
|
+
raise RateLimitError.new(
|
|
97
|
+
method,
|
|
98
|
+
description,
|
|
99
|
+
retry_after: parameters.fetch("retry_after"),
|
|
100
|
+
error_code: response["error_code"],
|
|
101
|
+
parameters: parameters
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
raise Error.new(
|
|
106
|
+
method,
|
|
107
|
+
description,
|
|
108
|
+
error_code: response["error_code"],
|
|
109
|
+
parameters: parameters
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def request(method, params)
|
|
114
|
+
uri = URI.parse("#{@base_url}/bot#{@token}/#{method}")
|
|
115
|
+
request = Net::HTTP::Post.new(uri)
|
|
116
|
+
request.set_form_data(params.transform_values(&:to_s))
|
|
117
|
+
|
|
118
|
+
response = Net::HTTP.start(
|
|
119
|
+
uri.host,
|
|
120
|
+
uri.port,
|
|
121
|
+
use_ssl: uri.scheme == "https",
|
|
122
|
+
open_timeout: 5,
|
|
123
|
+
read_timeout: params.fetch("timeout", 10).to_i + 10
|
|
124
|
+
) { |http| http.request(request) }
|
|
125
|
+
body = response.body.to_s
|
|
126
|
+
raise Error.new(method, "response was too large") if body.bytesize > MAX_RESPONSE_BYTES
|
|
127
|
+
|
|
128
|
+
JSON.parse(body)
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
raise Error.new(method, "response was not valid JSON")
|
|
131
|
+
rescue URI::InvalidURIError
|
|
132
|
+
raise Error.new(method, "invalid Telegram API URL")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
require_relative "telegram_api"
|
|
3
|
+
|
|
4
|
+
module Kward
|
|
5
|
+
module Telegram
|
|
6
|
+
# Long-polling Telegram adapter backed by normal Kward sessions.
|
|
7
|
+
class Transport
|
|
8
|
+
TELEGRAM_MESSAGE_LIMIT = 4096
|
|
9
|
+
DEFAULT_POLL_TIMEOUT = 25
|
|
10
|
+
MAX_RETRY_DELAY = 30
|
|
11
|
+
|
|
12
|
+
def initialize(host:, config:, api: nil, sleeper: nil)
|
|
13
|
+
@host = host
|
|
14
|
+
@config = config || {}
|
|
15
|
+
@token = host.secret("bot_token", env: "TELEGRAM_BOT_TOKEN")
|
|
16
|
+
@workspace = required_config("workspace")
|
|
17
|
+
@allowed_user_ids = required_ids("allowed_user_ids")
|
|
18
|
+
@allowed_chat_ids = required_ids("allowed_chat_ids")
|
|
19
|
+
@poll_timeout = Integer(@config.fetch("poll_timeout_seconds", DEFAULT_POLL_TIMEOUT))
|
|
20
|
+
raise ArgumentError, "poll_timeout_seconds must be between 0 and 50" unless (0..50).cover?(@poll_timeout)
|
|
21
|
+
|
|
22
|
+
@api = api || BotApi.new(token: @token)
|
|
23
|
+
@sleeper = sleeper || method(:sleep)
|
|
24
|
+
@state_mutex = Mutex.new
|
|
25
|
+
@stop = false
|
|
26
|
+
@thread = nil
|
|
27
|
+
@last_error = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def start
|
|
31
|
+
@api.delete_webhook
|
|
32
|
+
@api.get_me
|
|
33
|
+
@host.interactions.subscribe { |request| handle_interaction(request) }
|
|
34
|
+
@state_mutex.synchronize do
|
|
35
|
+
@stop = false
|
|
36
|
+
@last_error = nil
|
|
37
|
+
@thread = Thread.new { poll_loop }
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop
|
|
43
|
+
thread = @state_mutex.synchronize do
|
|
44
|
+
@stop = true
|
|
45
|
+
@thread
|
|
46
|
+
end
|
|
47
|
+
return nil unless thread
|
|
48
|
+
|
|
49
|
+
thread.join(@poll_timeout + 10)
|
|
50
|
+
thread.kill if thread.alive?
|
|
51
|
+
@state_mutex.synchronize { @thread = nil }
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def health
|
|
56
|
+
@state_mutex.synchronize do
|
|
57
|
+
{
|
|
58
|
+
state: @thread&.alive? ? "running" : "stopped",
|
|
59
|
+
error: @last_error
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def poll_loop
|
|
67
|
+
offset = Integer(@host.storage.get("telegram:offset") || 0)
|
|
68
|
+
until stopping?
|
|
69
|
+
begin
|
|
70
|
+
updates = @api.get_updates(offset: offset, timeout: @poll_timeout)
|
|
71
|
+
updates.each do |update|
|
|
72
|
+
update_id = Integer(update.fetch("update_id"))
|
|
73
|
+
handle_update(update)
|
|
74
|
+
offset = [offset, update_id + 1].max
|
|
75
|
+
@host.storage.put("telegram:offset", offset)
|
|
76
|
+
end
|
|
77
|
+
rescue BotApi::RateLimitError => error
|
|
78
|
+
record_error(error)
|
|
79
|
+
wait_for([error.retry_after, MAX_RETRY_DELAY].min)
|
|
80
|
+
rescue StandardError => error
|
|
81
|
+
record_error(error)
|
|
82
|
+
wait_for(2)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_update(update)
|
|
88
|
+
if update["callback_query"]
|
|
89
|
+
handle_callback(update.fetch("callback_query"))
|
|
90
|
+
elsif update["message"]
|
|
91
|
+
handle_message(update.fetch("message"), update.fetch("update_id"))
|
|
92
|
+
end
|
|
93
|
+
rescue StandardError => error
|
|
94
|
+
record_error(error)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_message(message, update_id)
|
|
98
|
+
text = message["text"].to_s
|
|
99
|
+
return if text.empty?
|
|
100
|
+
|
|
101
|
+
user_id = Integer(message.fetch("from").fetch("id"))
|
|
102
|
+
chat_id = Integer(message.fetch("chat").fetch("id"))
|
|
103
|
+
return unless allowed?(user_id, chat_id)
|
|
104
|
+
return unless @host.storage.claim("telegram:update:#{update_id}")
|
|
105
|
+
|
|
106
|
+
conversation = Kward::Transport.conversation_key(
|
|
107
|
+
transport_id: @host.transport_id,
|
|
108
|
+
external_id: "chat:#{chat_id}"
|
|
109
|
+
)
|
|
110
|
+
actor = Kward::Transport.actor(
|
|
111
|
+
id: "telegram:user:#{user_id}",
|
|
112
|
+
display_name: message.dig("from", "username") || user_id.to_s,
|
|
113
|
+
metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
|
|
114
|
+
)
|
|
115
|
+
@host.authorize!(:telegram_message, actor: actor, conversation: conversation)
|
|
116
|
+
|
|
117
|
+
session = @host.sessions.resolve(
|
|
118
|
+
conversation: conversation,
|
|
119
|
+
actor: actor,
|
|
120
|
+
workspace_root: @workspace,
|
|
121
|
+
name: "Telegram chat #{chat_id}"
|
|
122
|
+
)
|
|
123
|
+
remember_session(session, conversation, chat_id, actor)
|
|
124
|
+
|
|
125
|
+
turn = session.start_turn(text)
|
|
126
|
+
@host.storage.put("telegram:turn:#{turn.id}", {
|
|
127
|
+
"chat_id" => chat_id,
|
|
128
|
+
"session_id" => session.id,
|
|
129
|
+
"reply_to_message_id" => message["message_id"]
|
|
130
|
+
})
|
|
131
|
+
turn.subscribe { |event| handle_turn_event(event) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_turn_event(event)
|
|
135
|
+
return unless %w[answer assistantMessage assistant_message].include?(event.type.to_s)
|
|
136
|
+
|
|
137
|
+
turn = @host.storage.get("telegram:turn:#{event.turn_id}")
|
|
138
|
+
return unless turn
|
|
139
|
+
return if @host.storage.get("telegram:delivered:#{event.turn_id}")
|
|
140
|
+
|
|
141
|
+
content = event.payload[:content] || event.payload["content"]
|
|
142
|
+
if content.to_s.empty? && %w[assistantMessage assistant_message].include?(event.type.to_s)
|
|
143
|
+
message = event.payload[:message] || event.payload["message"]
|
|
144
|
+
content = message[:content] || message["content"] if message.is_a?(Hash)
|
|
145
|
+
end
|
|
146
|
+
return if content.to_s.empty?
|
|
147
|
+
|
|
148
|
+
split_message(content.to_s).each do |part|
|
|
149
|
+
@api.send_message(
|
|
150
|
+
chat_id: turn.fetch("chat_id"),
|
|
151
|
+
text: part,
|
|
152
|
+
reply_to_message_id: turn["reply_to_message_id"]
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
@host.storage.put("telegram:delivered:#{event.turn_id}", true)
|
|
156
|
+
rescue StandardError => error
|
|
157
|
+
record_error(error)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_interaction(request)
|
|
161
|
+
session = @host.storage.get("telegram:session:#{request.session_id}")
|
|
162
|
+
return unless session
|
|
163
|
+
|
|
164
|
+
record = {
|
|
165
|
+
"chat_id" => session.fetch("chat_id"),
|
|
166
|
+
"session_id" => request.session_id,
|
|
167
|
+
"external_id" => session.fetch("external_id"),
|
|
168
|
+
"actor_id" => session.fetch("actor_id"),
|
|
169
|
+
"actor_name" => session["actor_name"],
|
|
170
|
+
"kind" => request.kind.to_s
|
|
171
|
+
}
|
|
172
|
+
buttons = case request.kind.to_s
|
|
173
|
+
when "tool_approval"
|
|
174
|
+
record["answers"] = { "approve" => true, "deny" => false }
|
|
175
|
+
[[
|
|
176
|
+
{ "text" => "Approve", "callback_data" => callback_data(request.id, "approve") },
|
|
177
|
+
{ "text" => "Deny", "callback_data" => callback_data(request.id, "deny") }
|
|
178
|
+
]]
|
|
179
|
+
when "question"
|
|
180
|
+
question, options = question_options(request)
|
|
181
|
+
return if options.empty?
|
|
182
|
+
|
|
183
|
+
record["question"] = question
|
|
184
|
+
record["answers"] = options.each_with_index.to_h { |option, index| [index.to_s, option] }
|
|
185
|
+
[options.each_with_index.map do |option, index|
|
|
186
|
+
{ "text" => option, "callback_data" => callback_data(request.id, index.to_s) }
|
|
187
|
+
end]
|
|
188
|
+
else
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
@host.storage.put("telegram:interaction:#{request.id}", record)
|
|
193
|
+
@api.send_message(
|
|
194
|
+
chat_id: record.fetch("chat_id"),
|
|
195
|
+
text: request.prompt,
|
|
196
|
+
reply_markup: { "inline_keyboard" => buttons }
|
|
197
|
+
)
|
|
198
|
+
rescue StandardError => error
|
|
199
|
+
record_error(error)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_callback(callback)
|
|
203
|
+
data = callback["data"].to_s
|
|
204
|
+
match = /\Akward:([^:]+):([^:]+)\z/.match(data)
|
|
205
|
+
return unless match
|
|
206
|
+
|
|
207
|
+
request_id, choice = match.captures
|
|
208
|
+
chat_id = Integer(callback.fetch("message").fetch("chat").fetch("id"))
|
|
209
|
+
user_id = Integer(callback.fetch("from").fetch("id"))
|
|
210
|
+
return unless allowed?(user_id, chat_id)
|
|
211
|
+
|
|
212
|
+
record = @host.storage.get("telegram:interaction:#{request_id}")
|
|
213
|
+
return unless record && record.fetch("chat_id") == chat_id
|
|
214
|
+
return unless record.fetch("actor_id") == "telegram:user:#{user_id}"
|
|
215
|
+
return if @host.storage.get("telegram:answered:#{request_id}")
|
|
216
|
+
|
|
217
|
+
conversation = Kward::Transport.conversation_key(
|
|
218
|
+
transport_id: @host.transport_id,
|
|
219
|
+
external_id: record.fetch("external_id")
|
|
220
|
+
)
|
|
221
|
+
actor = Kward::Transport.actor(
|
|
222
|
+
id: record.fetch("actor_id"),
|
|
223
|
+
display_name: record["actor_name"],
|
|
224
|
+
metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
|
|
225
|
+
)
|
|
226
|
+
session = @host.sessions.resolve(
|
|
227
|
+
conversation: conversation,
|
|
228
|
+
actor: actor,
|
|
229
|
+
workspace_root: @workspace
|
|
230
|
+
)
|
|
231
|
+
answer = record.fetch("answers").fetch(choice)
|
|
232
|
+
answer = [{ question: record.fetch("question"), answer: answer }] if record.fetch("kind") == "question"
|
|
233
|
+
session.answer_interaction(request_id: request_id, answer: answer)
|
|
234
|
+
@host.storage.put("telegram:answered:#{request_id}", true)
|
|
235
|
+
@api.answer_callback_query(callback_query_id: callback.fetch("id"))
|
|
236
|
+
rescue StandardError => error
|
|
237
|
+
record_error(error)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def remember_session(session, conversation, chat_id, actor)
|
|
241
|
+
@host.storage.put("telegram:session:#{session.id}", {
|
|
242
|
+
"chat_id" => chat_id,
|
|
243
|
+
"external_id" => conversation.external_id,
|
|
244
|
+
"actor_id" => actor.id,
|
|
245
|
+
"actor_name" => actor.display_name
|
|
246
|
+
})
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def question_options(request)
|
|
250
|
+
question = request.choices.first || {}
|
|
251
|
+
options = Array(question[:options] || question["options"]).map do |option|
|
|
252
|
+
(option[:label] || option["label"]).to_s
|
|
253
|
+
end.reject(&:empty?)
|
|
254
|
+
[question[:question] || question["question"] || request.prompt, options]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def callback_data(request_id, choice)
|
|
258
|
+
"kward:#{request_id}:#{choice}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def split_message(text)
|
|
262
|
+
text.each_char.each_slice(TELEGRAM_MESSAGE_LIMIT).map(&:join)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def allowed?(user_id, chat_id)
|
|
266
|
+
@allowed_user_ids.include?(user_id) && @allowed_chat_ids.include?(chat_id)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def stopping?
|
|
270
|
+
@state_mutex.synchronize { @stop }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def wait_for(seconds)
|
|
274
|
+
@sleeper.call(seconds) unless stopping?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def record_error(error)
|
|
278
|
+
message = error.message.to_s.gsub(@token.to_s, "[REDACTED]")
|
|
279
|
+
summary = "#{error.class}: #{message}"
|
|
280
|
+
@state_mutex.synchronize { @last_error = summary }
|
|
281
|
+
@host.logger.error("Telegram transport error: #{summary}")
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def required_config(key)
|
|
287
|
+
value = @config[key] || @config[key.to_sym]
|
|
288
|
+
raise ArgumentError, "Telegram transport requires #{key}" if value.to_s.empty?
|
|
289
|
+
|
|
290
|
+
value.to_s
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def required_ids(key)
|
|
294
|
+
values = @config[key] || @config[key.to_sym]
|
|
295
|
+
values = Array(values).map { |value| Integer(value) }
|
|
296
|
+
raise ArgumentError, "Telegram transport requires non-empty #{key}" if values.empty?
|
|
297
|
+
|
|
298
|
+
values.uniq.freeze
|
|
299
|
+
rescue ArgumentError, TypeError
|
|
300
|
+
raise ArgumentError, "Telegram transport #{key} must contain numeric IDs"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/kward/ansi.rb
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../config_files"
|
|
3
|
+
require_relative "../private_file"
|
|
4
|
+
require_relative "../model/provider_catalog"
|
|
5
|
+
|
|
6
|
+
# Namespace for Kward credential storage.
|
|
7
|
+
module Kward
|
|
8
|
+
# Stores API keys outside the non-secret Kward configuration file.
|
|
9
|
+
class APIKeyStore
|
|
10
|
+
def initialize(path: APIKeyStore.default_path, config_path: ConfigFiles.config_path, env: ENV)
|
|
11
|
+
@path = File.expand_path(path)
|
|
12
|
+
@config_path = File.expand_path(config_path)
|
|
13
|
+
@env = env
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :path
|
|
17
|
+
|
|
18
|
+
def self.default_path
|
|
19
|
+
File.join(ConfigFiles.config_dir, "api_keys.json")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(provider_id)
|
|
23
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
24
|
+
environment_key(provider) || stored_key(provider.id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configured?(provider_id)
|
|
28
|
+
!fetch(provider_id).to_s.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def store(provider_id, api_key)
|
|
32
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
33
|
+
raise ArgumentError, "#{provider.name} does not accept an API key" unless provider.api_key?
|
|
34
|
+
|
|
35
|
+
api_key = api_key.to_s.strip
|
|
36
|
+
raise ArgumentError, "API key must be a non-empty string" if api_key.empty?
|
|
37
|
+
|
|
38
|
+
keys = stored_keys
|
|
39
|
+
keys[provider.id] = api_key
|
|
40
|
+
write_keys(keys)
|
|
41
|
+
path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(provider_id)
|
|
45
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
46
|
+
keys = stored_keys
|
|
47
|
+
return false unless keys.delete(provider.id)
|
|
48
|
+
|
|
49
|
+
write_keys(keys)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stored?(provider_id)
|
|
54
|
+
!stored_key(provider_id).to_s.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stored_value(provider_id)
|
|
58
|
+
ProviderCatalog.fetch(provider_id)
|
|
59
|
+
stored_key(provider_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def migrate_openrouter_config_key!
|
|
63
|
+
return false if stored?("openrouter")
|
|
64
|
+
|
|
65
|
+
config = ConfigFiles.read_config(@config_path)
|
|
66
|
+
key = config["openrouter_api_key"].to_s.strip
|
|
67
|
+
return false if key.empty?
|
|
68
|
+
|
|
69
|
+
store("openrouter", key)
|
|
70
|
+
ConfigFiles.delete_config_key("openrouter_api_key", @config_path)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def environment_key(provider)
|
|
77
|
+
provider.api_key_env.map { |name| @env[name].to_s.strip }.find { |key| !key.empty? }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stored_key(provider_id)
|
|
81
|
+
stored_keys[provider_id.to_s]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def stored_keys
|
|
85
|
+
return {} unless File.exist?(path)
|
|
86
|
+
|
|
87
|
+
data = JSON.parse(File.read(path))
|
|
88
|
+
raise "Invalid Kward API key file: #{path}" unless data.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
data.transform_keys(&:to_s).transform_values(&:to_s)
|
|
91
|
+
rescue JSON::ParserError => e
|
|
92
|
+
raise "Invalid Kward API key file: #{path}: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_keys(keys)
|
|
96
|
+
PrivateFile.write_json(path, keys)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -22,12 +22,15 @@ module Kward
|
|
|
22
22
|
|
|
23
23
|
# Writes the auth status output for the terminal CLI flow.
|
|
24
24
|
def print_auth_status
|
|
25
|
-
|
|
25
|
+
store = api_key_store
|
|
26
|
+
store.migrate_openrouter_config_key!
|
|
26
27
|
lines = ["#{colored("Auth Status", :green, :bold)}", ""]
|
|
27
28
|
lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
|
|
28
29
|
lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
|
|
29
30
|
lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
|
|
30
|
-
|
|
31
|
+
ProviderCatalog.api_key_providers.each do |provider|
|
|
32
|
+
lines << auth_status_line("#{provider.name} API key", store.configured?(provider.id), store.path)
|
|
33
|
+
end
|
|
31
34
|
@prompt.say lines.join("\n")
|
|
32
35
|
end
|
|
33
36
|
|
|
@@ -45,7 +48,11 @@ module Kward
|
|
|
45
48
|
File.delete(path)
|
|
46
49
|
removed << path
|
|
47
50
|
end
|
|
48
|
-
|
|
51
|
+
store = api_key_store
|
|
52
|
+
store.migrate_openrouter_config_key!
|
|
53
|
+
ProviderCatalog.api_key_providers.each do |provider|
|
|
54
|
+
removed << "#{provider.name} API key" if store.delete(provider.id)
|
|
55
|
+
end
|
|
49
56
|
|
|
50
57
|
if removed.empty?
|
|
51
58
|
@prompt.say "No saved credentials found."
|
|
@@ -54,29 +61,81 @@ module Kward
|
|
|
54
61
|
end
|
|
55
62
|
end
|
|
56
63
|
|
|
57
|
-
def login(provider: nil, oauth: nil)
|
|
58
|
-
provider = provider
|
|
59
|
-
if provider
|
|
60
|
-
|
|
61
|
-
path = auth.login(prompt: @prompt)
|
|
62
|
-
@prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
|
|
64
|
+
def login(provider: nil, oauth: nil, auth_method: nil)
|
|
65
|
+
provider = normalized_login_provider(provider)
|
|
66
|
+
if api_key_login?(provider, auth_method)
|
|
67
|
+
login_with_api_key(provider)
|
|
63
68
|
return
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
oauth ||= case provider
|
|
67
|
-
when "github" then GithubOAuth.new
|
|
68
|
-
when "anthropic"
|
|
72
|
+
when "github", "copilot" then GithubOAuth.new
|
|
73
|
+
when "anthropic" then AnthropicOAuth.new
|
|
69
74
|
else OpenAIOAuth.new
|
|
70
75
|
end
|
|
71
76
|
path = oauth.login(prompt: @prompt)
|
|
72
77
|
name = case provider
|
|
73
|
-
when "github" then "GitHub"
|
|
74
|
-
when "anthropic"
|
|
78
|
+
when "github", "copilot" then "GitHub"
|
|
79
|
+
when "anthropic" then "Anthropic"
|
|
75
80
|
else "OpenAI"
|
|
76
81
|
end
|
|
77
82
|
@prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
|
|
78
83
|
end
|
|
79
84
|
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def api_key_store
|
|
88
|
+
@api_key_store ||= APIKeyStore.new
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalized_login_provider(provider)
|
|
92
|
+
value = provider.to_s.downcase
|
|
93
|
+
return "openai" if value.empty?
|
|
94
|
+
return "anthropic" if ["anthropic", "claude"].include?(value)
|
|
95
|
+
return "copilot" if ["github", "copilot"].include?(value)
|
|
96
|
+
|
|
97
|
+
ProviderCatalog.fetch(value).id
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def api_key_login?(provider, auth_method)
|
|
101
|
+
return true if auth_method == :api_key || auth_method == "api_key"
|
|
102
|
+
|
|
103
|
+
ProviderCatalog.fetch(provider).api_key? && !["openai", "anthropic", "copilot"].include?(provider)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def login_with_api_key(provider_id)
|
|
107
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
108
|
+
raise "#{provider.name} does not accept an API key" unless provider.api_key?
|
|
109
|
+
|
|
110
|
+
api_key = @prompt.ask("#{provider.name} API key:").to_s.strip
|
|
111
|
+
provider_config = provider_config_after_api_key_login(provider)
|
|
112
|
+
models = refresh_provider_models(provider, api_key)
|
|
113
|
+
path = api_key_store.store(provider.id, api_key)
|
|
114
|
+
ConfigFiles.update_config(provider_config) unless provider_config.empty?
|
|
115
|
+
@prompt.say("#{colored("Saved", :green, :bold)} #{provider.name} API key to #{path}")
|
|
116
|
+
@prompt.say("Loaded #{models.length} #{provider.name} model#{models.length == 1 ? "" : "s"}.") if models
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def refresh_provider_models(provider, api_key)
|
|
120
|
+
return unless ProviderCatalog.runtime(provider.id).automatic_model_discovery?
|
|
121
|
+
|
|
122
|
+
model_catalog(provider_id: provider.id, api_key: api_key).refresh
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def model_catalog(provider_id:, api_key:)
|
|
126
|
+
ModelCatalog.new(provider_id: provider_id, api_key: api_key)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def provider_config_after_api_key_login(provider)
|
|
130
|
+
return {} unless provider.id == "azure_openai"
|
|
131
|
+
|
|
132
|
+
AzureOpenAIConfig.new(
|
|
133
|
+
endpoint: @prompt.ask("Azure OpenAI endpoint:"),
|
|
134
|
+
deployment: @prompt.ask("Azure OpenAI deployment name:"),
|
|
135
|
+
api_version: @prompt.ask("Azure OpenAI API version:")
|
|
136
|
+
).to_config
|
|
137
|
+
end
|
|
138
|
+
|
|
80
139
|
end
|
|
81
140
|
end
|
|
82
141
|
end
|