kodo-bot 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 +21 -0
- data/README.md +157 -0
- data/bin/kodo +160 -0
- data/config/default.yml +40 -0
- data/lib/kodo/channels/base.rb +36 -0
- data/lib/kodo/channels/console.rb +45 -0
- data/lib/kodo/channels/telegram.rb +138 -0
- data/lib/kodo/config.rb +142 -0
- data/lib/kodo/daemon.rb +79 -0
- data/lib/kodo/heartbeat.rb +97 -0
- data/lib/kodo/llm.rb +30 -0
- data/lib/kodo/memory/audit.rb +55 -0
- data/lib/kodo/memory/store.rb +77 -0
- data/lib/kodo/message.rb +26 -0
- data/lib/kodo/prompt_assembler.rb +228 -0
- data/lib/kodo/router.rb +65 -0
- data/lib/kodo/version.rb +5 -0
- data/lib/kodo.rb +40 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 65b530bf1300200558b1ece1066f34868e8b97c675f911b1d2c9c005e46cdb46
|
|
4
|
+
data.tar.gz: f482bb17f09d346b3b21f1de00e88cea25f7c8685e14d7b1319459442b05148f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6f19367ea55d9172468c4b3b3810f95a08102b09829bb7460d461278bf6b72f692a383d68ae9d91dcc661d69ecb28be25a4654e3a1f5e5f813f41fcb2be7bf7c
|
|
7
|
+
data.tar.gz: a5c012da9b6ffd653c1fa042bb983e8d245c22edbf85d9af30ab15b4cccfc8e85a170a90a54eb5ad52fc20bb60384cdce2de10924e847ef347e0e8b6b6c7bf81
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Freedom Dumlao
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# π₯ Kodo
|
|
2
|
+
|
|
3
|
+
**Kodo** (ιΌε, "heartbeat") is an open-source, security-first AI agent framework
|
|
4
|
+
written in Ruby. It runs locally on your hardware and communicates through the
|
|
5
|
+
messaging platforms you already use.
|
|
6
|
+
|
|
7
|
+
Unlike cloud-hosted AI assistants, Kodo keeps your data on your machine, enforces
|
|
8
|
+
capability-based permissions on every action, and gives you full control over
|
|
9
|
+
what your agent can and cannot do.
|
|
10
|
+
|
|
11
|
+
> **Status:** Early development. Phase 1 β Foundation.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Prerequisites
|
|
16
|
+
|
|
17
|
+
- Ruby 3.2+
|
|
18
|
+
- An API key for any supported LLM provider:
|
|
19
|
+
[Anthropic](https://console.anthropic.com/),
|
|
20
|
+
[OpenAI](https://platform.openai.com/),
|
|
21
|
+
[Gemini](https://aistudio.google.com/),
|
|
22
|
+
[Ollama](https://ollama.com/) (free, local), and
|
|
23
|
+
[many more](https://rubyllm.com/)
|
|
24
|
+
- A Telegram Bot Token (message [@BotFather](https://t.me/BotFather) on Telegram)
|
|
25
|
+
|
|
26
|
+
### Setup
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/apiguy/kodo.git
|
|
30
|
+
cd kodo
|
|
31
|
+
bundle install
|
|
32
|
+
|
|
33
|
+
# Initialize Kodo's home directory
|
|
34
|
+
ruby bin/kodo init
|
|
35
|
+
|
|
36
|
+
# Set your LLM API key (pick any provider)
|
|
37
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
38
|
+
# or: export OPENAI_API_KEY="sk-..."
|
|
39
|
+
# or: just run Ollama locally β no key needed
|
|
40
|
+
|
|
41
|
+
# Set up Telegram
|
|
42
|
+
export TELEGRAM_BOT_TOKEN="123456:ABC-DEF..."
|
|
43
|
+
|
|
44
|
+
# Enable Telegram and set your model in the config
|
|
45
|
+
# Edit ~/.kodo/config.yml
|
|
46
|
+
|
|
47
|
+
# Start Kodo
|
|
48
|
+
ruby bin/kodo start
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Now message your bot on Telegram. Kodo is alive.
|
|
52
|
+
|
|
53
|
+
### CLI Chat (no Telegram needed)
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export ANTHROPIC_API_KEY="sk-ant-..." # or any provider key
|
|
57
|
+
ruby bin/kodo chat
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
kodo start Start the Kodo daemon
|
|
64
|
+
kodo chat Chat with Kodo directly in the terminal
|
|
65
|
+
kodo status Show daemon status
|
|
66
|
+
kodo init Create default config in ~/.kodo/
|
|
67
|
+
kodo version Show version
|
|
68
|
+
kodo help Show help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How It Works
|
|
72
|
+
|
|
73
|
+
Kodo runs a **heartbeat loop** β a periodic cycle that polls your messaging
|
|
74
|
+
channels for new messages, processes them through an LLM, and sends responses
|
|
75
|
+
back. This heartbeat is what makes Kodo an agent rather than a chatbot: it runs
|
|
76
|
+
continuously, can notice things, and will eventually take proactive action on
|
|
77
|
+
your behalf.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
Your Phone (Telegram) ββ Telegram API ββ Kodo Daemon ββ Anthropic Claude
|
|
81
|
+
β
|
|
82
|
+
Memory Store
|
|
83
|
+
(conversations,
|
|
84
|
+
audit trail)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Architecture
|
|
88
|
+
|
|
89
|
+
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design, component
|
|
90
|
+
details, and phase roadmap.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
Kodo stores its config and data in `~/.kodo/`:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
~/.kodo/
|
|
98
|
+
βββ config.yml # LLM provider and channel settings
|
|
99
|
+
βββ persona.md # Agent personality and tone (make Kodo yours)
|
|
100
|
+
βββ user.md # Tell Kodo about yourself
|
|
101
|
+
βββ pulse.md # What to notice during idle beats
|
|
102
|
+
βββ origin.md # First-run onboarding conversation
|
|
103
|
+
βββ memory/
|
|
104
|
+
βββ conversations/ # Chat history (per-conversation JSON)
|
|
105
|
+
βββ knowledge/ # Long-term memory (future)
|
|
106
|
+
βββ audit/ # Daily audit logs (JSONL)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Prompt Files
|
|
110
|
+
|
|
111
|
+
Kodo's personality is defined by **plain Markdown files**, not code. Edit
|
|
112
|
+
them to make the agent yours:
|
|
113
|
+
|
|
114
|
+
- **`persona.md`** β How Kodo talks. Tone, style, opinions. "Respond like
|
|
115
|
+
a senior engineer doing code review" is more useful than "be helpful."
|
|
116
|
+
- **`user.md`** β Who you are. Name, role, timezone, current projects.
|
|
117
|
+
Helps Kodo give contextual answers.
|
|
118
|
+
- **`pulse.md`** β What Kodo should pay attention to during idle heartbeat
|
|
119
|
+
cycles. "Remind me about standup at 9:45am" or "summarize unread messages
|
|
120
|
+
if more than 5 accumulate."
|
|
121
|
+
- **`origin.md`** β Runs on first conversation only. Kodo introduces itself
|
|
122
|
+
and helps you set up.
|
|
123
|
+
|
|
124
|
+
These files are **advisory** β they shape behavior but cannot override Kodo's
|
|
125
|
+
hardcoded security invariants (no data exfiltration, no prompt injection
|
|
126
|
+
compliance, no impersonation).
|
|
127
|
+
|
|
128
|
+
Secrets (API keys, bot tokens) are never stored in config files. Instead, config
|
|
129
|
+
references environment variable names using the `_env` suffix convention:
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
llm:
|
|
133
|
+
api_key_env: ANTHROPIC_API_KEY # reads $ANTHROPIC_API_KEY at runtime
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Security
|
|
137
|
+
|
|
138
|
+
Kodo is being built security-first. The current phase has basic protections;
|
|
139
|
+
future phases will add:
|
|
140
|
+
|
|
141
|
+
- **Capability-based permissions** β skills declare what they need, you grant
|
|
142
|
+
scoped access
|
|
143
|
+
- **Sandboxed skill execution** β skills run in isolated processes
|
|
144
|
+
- **Signed skills** β cryptographic verification before loading any skill
|
|
145
|
+
- **Encrypted memory** β conversation history encrypted at rest
|
|
146
|
+
- **Audit trail** β every action logged with what triggered it and what
|
|
147
|
+
permissions were used
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
|
152
|
+
|
|
153
|
+
## Links
|
|
154
|
+
|
|
155
|
+
- **Website:** [kodo.bot](https://kodo.bot)
|
|
156
|
+
- **Architecture:** [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
157
|
+
- **Development Guide:** [CLAUDE.md](CLAUDE.md)
|
data/bin/kodo
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/kodo"
|
|
5
|
+
|
|
6
|
+
module Kodo
|
|
7
|
+
class CLI
|
|
8
|
+
COMMANDS = {
|
|
9
|
+
"start" => "Start the Kodo daemon",
|
|
10
|
+
"chat" => "Chat with Kodo directly in the terminal",
|
|
11
|
+
"status" => "Show daemon status",
|
|
12
|
+
"version" => "Show version",
|
|
13
|
+
"init" => "Create default config and prompt files in ~/.kodo/",
|
|
14
|
+
"help" => "Show this help"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def run(args = ARGV)
|
|
18
|
+
command = args.first || "help"
|
|
19
|
+
|
|
20
|
+
case command
|
|
21
|
+
when "start" then start(args)
|
|
22
|
+
when "chat" then chat
|
|
23
|
+
when "init" then init
|
|
24
|
+
when "version" then puts "kodo v#{VERSION}"
|
|
25
|
+
when "status" then status
|
|
26
|
+
else help
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def start(args)
|
|
33
|
+
interval = nil
|
|
34
|
+
args.each do |arg|
|
|
35
|
+
if arg.start_with?("--heartbeat-interval=")
|
|
36
|
+
interval = arg.split("=", 2).last.to_i
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
if (idx = args.index("--heartbeat-interval")) && !args[idx + 1]&.start_with?("--")
|
|
40
|
+
interval = args[idx + 1]&.to_i
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
daemon = Daemon.new(heartbeat_interval: interval)
|
|
44
|
+
daemon.start!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def chat
|
|
48
|
+
Config.ensure_home_dir!
|
|
49
|
+
assembler = PromptAssembler.new
|
|
50
|
+
assembler.ensure_default_files!
|
|
51
|
+
LLM.configure!(Kodo.config)
|
|
52
|
+
|
|
53
|
+
puts "π₯ Kodo v#{VERSION} β direct chat mode"
|
|
54
|
+
puts " Model: #{Kodo.config.llm_model}"
|
|
55
|
+
puts " Type your message and press Enter. Ctrl+C to quit.\n\n"
|
|
56
|
+
|
|
57
|
+
memory = Memory::Store.new
|
|
58
|
+
audit = Memory::Audit.new
|
|
59
|
+
router = Router.new(memory: memory, audit: audit, prompt_assembler: assembler)
|
|
60
|
+
console = Channels::Console.new
|
|
61
|
+
console.connect!
|
|
62
|
+
|
|
63
|
+
loop do
|
|
64
|
+
print "\e[33mYou:\e[0m "
|
|
65
|
+
input = $stdin.gets&.strip
|
|
66
|
+
break if input.nil? || input.empty?
|
|
67
|
+
|
|
68
|
+
message = Message.new(
|
|
69
|
+
channel_id: "console",
|
|
70
|
+
sender: :user,
|
|
71
|
+
content: input,
|
|
72
|
+
metadata: { chat_id: "console" }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
response = router.route(message, channel: console)
|
|
76
|
+
console.send_message(response)
|
|
77
|
+
end
|
|
78
|
+
rescue Interrupt
|
|
79
|
+
puts "\n\nπ₯ Goodbye."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def init
|
|
83
|
+
Config.ensure_home_dir!
|
|
84
|
+
PromptAssembler.new.ensure_default_files!
|
|
85
|
+
|
|
86
|
+
puts "β
Kodo home directory: #{Kodo.home_dir}"
|
|
87
|
+
puts ""
|
|
88
|
+
puts " Created files:"
|
|
89
|
+
puts " π config.yml β LLM provider and channel settings"
|
|
90
|
+
puts " π persona.md β personality and tone (make Kodo yours)"
|
|
91
|
+
puts " π€ user.md β tell Kodo about yourself"
|
|
92
|
+
puts " π pulse.md β what to notice during idle beats"
|
|
93
|
+
puts " π± origin.md β first-run onboarding conversation"
|
|
94
|
+
puts ""
|
|
95
|
+
puts " Quick start:"
|
|
96
|
+
puts " 1. Set an LLM API key (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY)"
|
|
97
|
+
puts " 2. Set TELEGRAM_BOT_TOKEN (get one from @BotFather on Telegram)"
|
|
98
|
+
puts " 3. Enable Telegram in ~/.kodo/config.yml"
|
|
99
|
+
puts " 4. Edit ~/.kodo/persona.md to customize Kodo's personality"
|
|
100
|
+
puts " 5. Run: kodo start"
|
|
101
|
+
puts ""
|
|
102
|
+
puts " Supported LLM providers:"
|
|
103
|
+
puts " Anthropic, OpenAI, Gemini, DeepSeek, Mistral, Ollama,"
|
|
104
|
+
puts " OpenRouter, Perplexity, xAI, and any OpenAI-compatible API"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def status
|
|
108
|
+
puts "π₯ Kodo v#{VERSION}"
|
|
109
|
+
puts ""
|
|
110
|
+
puts " Home: #{Kodo.home_dir}"
|
|
111
|
+
puts " Config: #{File.exist?(Config.config_path) ? "β
" : "β"} #{Config.config_path}"
|
|
112
|
+
puts ""
|
|
113
|
+
|
|
114
|
+
# Prompt files
|
|
115
|
+
puts " Prompt files:"
|
|
116
|
+
%w[persona.md user.md pulse.md origin.md].each do |f|
|
|
117
|
+
path = File.join(Kodo.home_dir, f)
|
|
118
|
+
status = File.exist?(path) ? "β
" : " "
|
|
119
|
+
puts " #{status} #{f}"
|
|
120
|
+
end
|
|
121
|
+
puts ""
|
|
122
|
+
|
|
123
|
+
# Provider keys
|
|
124
|
+
puts " LLM providers:"
|
|
125
|
+
{
|
|
126
|
+
"ANTHROPIC_API_KEY" => "Anthropic",
|
|
127
|
+
"OPENAI_API_KEY" => "OpenAI",
|
|
128
|
+
"GEMINI_API_KEY" => "Gemini",
|
|
129
|
+
"OLLAMA_API_BASE" => "Ollama"
|
|
130
|
+
}.each do |env, name|
|
|
131
|
+
s = ENV[env] ? "β
set" : " not set"
|
|
132
|
+
puts " #{s.start_with?("β
") ? "β
" : " "} #{name.ljust(12)} (#{env})"
|
|
133
|
+
end
|
|
134
|
+
puts ""
|
|
135
|
+
|
|
136
|
+
puts " Telegram: #{ENV["TELEGRAM_BOT_TOKEN"] ? "β
set" : "β TELEGRAM_BOT_TOKEN missing"}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def help
|
|
140
|
+
puts "π₯ Kodo v#{VERSION} β your personal AI agent"
|
|
141
|
+
puts ""
|
|
142
|
+
puts "Usage: kodo <command> [options]"
|
|
143
|
+
puts ""
|
|
144
|
+
COMMANDS.each do |cmd, desc|
|
|
145
|
+
puts " %-12s %s" % [cmd, desc]
|
|
146
|
+
end
|
|
147
|
+
puts ""
|
|
148
|
+
puts "Options:"
|
|
149
|
+
puts " --heartbeat-interval=N Set heartbeat interval in seconds (default: 60)"
|
|
150
|
+
puts ""
|
|
151
|
+
puts "Prompt files in ~/.kodo/:"
|
|
152
|
+
puts " persona.md Your agent's personality and tone"
|
|
153
|
+
puts " user.md Tell Kodo about yourself"
|
|
154
|
+
puts " pulse.md What to notice during idle beats"
|
|
155
|
+
puts " origin.md First-run onboarding conversation"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Kodo::CLI.new.run
|
data/config/default.yml
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
daemon:
|
|
3
|
+
port: 7377
|
|
4
|
+
heartbeat_interval: 60
|
|
5
|
+
|
|
6
|
+
llm:
|
|
7
|
+
# Any model supported by your configured providers
|
|
8
|
+
# Examples: claude-sonnet-4-20250514, gpt-4o, gemini-2.5-pro, llama3:8b
|
|
9
|
+
model: claude-sonnet-4-20250514
|
|
10
|
+
|
|
11
|
+
# Add API keys for any providers you want to use.
|
|
12
|
+
# Only configure the ones you need β Kodo won't complain about the rest.
|
|
13
|
+
providers:
|
|
14
|
+
anthropic:
|
|
15
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
16
|
+
# openai:
|
|
17
|
+
# api_key_env: OPENAI_API_KEY
|
|
18
|
+
# gemini:
|
|
19
|
+
# api_key_env: GEMINI_API_KEY
|
|
20
|
+
# deepseek:
|
|
21
|
+
# api_key_env: DEEPSEEK_API_KEY
|
|
22
|
+
# mistral:
|
|
23
|
+
# api_key_env: MISTRAL_API_KEY
|
|
24
|
+
# openrouter:
|
|
25
|
+
# api_key_env: OPENROUTER_API_KEY
|
|
26
|
+
# ollama:
|
|
27
|
+
# api_base: http://localhost:11434 # no API key needed
|
|
28
|
+
|
|
29
|
+
channels:
|
|
30
|
+
telegram:
|
|
31
|
+
enabled: false
|
|
32
|
+
bot_token_env: TELEGRAM_BOT_TOKEN
|
|
33
|
+
|
|
34
|
+
memory:
|
|
35
|
+
encryption: false
|
|
36
|
+
store: file
|
|
37
|
+
|
|
38
|
+
logging:
|
|
39
|
+
level: info
|
|
40
|
+
audit: true
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kodo
|
|
4
|
+
module Channels
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :channel_id
|
|
7
|
+
|
|
8
|
+
def initialize(channel_id:)
|
|
9
|
+
@channel_id = channel_id
|
|
10
|
+
@running = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def connect!
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def disconnect!
|
|
18
|
+
@running = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Check for new messages. Returns Array<Kodo::Message>
|
|
22
|
+
def poll
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Send a message through this channel
|
|
27
|
+
def send_message(message)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def running?
|
|
32
|
+
@running
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kodo
|
|
4
|
+
module Channels
|
|
5
|
+
class Console < Base
|
|
6
|
+
def initialize
|
|
7
|
+
@inbox = Queue.new
|
|
8
|
+
super(channel_id: "console")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def connect!
|
|
12
|
+
@running = true
|
|
13
|
+
Kodo.logger.info("Console channel ready")
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def disconnect!
|
|
18
|
+
@running = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def poll
|
|
22
|
+
messages = []
|
|
23
|
+
messages << @inbox.pop(true) until @inbox.empty?
|
|
24
|
+
messages
|
|
25
|
+
rescue ThreadError
|
|
26
|
+
# Queue.pop(true) raises ThreadError when empty
|
|
27
|
+
messages
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def send_message(message)
|
|
31
|
+
puts "\n\e[36mKodo:\e[0m #{message.content}\n\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Push a message into the inbox (called from CLI input thread)
|
|
35
|
+
def push(text)
|
|
36
|
+
@inbox << Message.new(
|
|
37
|
+
channel_id: channel_id,
|
|
38
|
+
sender: :user,
|
|
39
|
+
content: text,
|
|
40
|
+
metadata: { chat_id: "console" }
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Kodo
|
|
8
|
+
module Channels
|
|
9
|
+
class Telegram < Base
|
|
10
|
+
API_BASE = "https://api.telegram.org"
|
|
11
|
+
|
|
12
|
+
def initialize(bot_token:)
|
|
13
|
+
@bot_token = bot_token
|
|
14
|
+
@last_update_id = 0
|
|
15
|
+
@allowed_chat_ids = [] # empty = allow all (for now)
|
|
16
|
+
super(channel_id: "telegram")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def connect!
|
|
20
|
+
# Verify the bot token works
|
|
21
|
+
me = api_request("getMe")
|
|
22
|
+
bot_name = me.dig("result", "username")
|
|
23
|
+
Kodo.logger.info("Telegram connected as @#{bot_name}")
|
|
24
|
+
@running = true
|
|
25
|
+
self
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
raise Error, "Failed to connect Telegram: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def disconnect!
|
|
31
|
+
@running = false
|
|
32
|
+
Kodo.logger.info("Telegram disconnected")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Poll for new messages using long polling with a short timeout
|
|
36
|
+
# Returns Array<Kodo::Message>
|
|
37
|
+
def poll
|
|
38
|
+
return [] unless running?
|
|
39
|
+
|
|
40
|
+
params = {
|
|
41
|
+
offset: @last_update_id + 1,
|
|
42
|
+
timeout: 1, # short poll β we're inside a heartbeat loop
|
|
43
|
+
allowed_updates: ["message"]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
response = api_request("getUpdates", params)
|
|
47
|
+
updates = response.dig("result") || []
|
|
48
|
+
|
|
49
|
+
messages = updates.filter_map do |update|
|
|
50
|
+
@last_update_id = update["update_id"]
|
|
51
|
+
parse_update(update)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
messages
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Kodo.logger.warn("Telegram poll error: #{e.message}")
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def send_message(message)
|
|
61
|
+
chat_id = message.metadata[:chat_id] || message.metadata["chat_id"]
|
|
62
|
+
return unless chat_id
|
|
63
|
+
|
|
64
|
+
params = {
|
|
65
|
+
chat_id: chat_id,
|
|
66
|
+
text: message.content,
|
|
67
|
+
parse_mode: "Markdown"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# If replying to a specific message
|
|
71
|
+
if (reply_to = message.metadata[:reply_to_message_id] || message.metadata["reply_to_message_id"])
|
|
72
|
+
params[:reply_to_message_id] = reply_to
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
api_request("sendMessage", params)
|
|
76
|
+
Kodo.logger.debug("Sent Telegram message to chat #{chat_id}")
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
Kodo.logger.error("Telegram send error: #{e.message}")
|
|
79
|
+
|
|
80
|
+
# Retry without markdown if parse failed
|
|
81
|
+
if e.message.include?("parse")
|
|
82
|
+
params.delete(:parse_mode)
|
|
83
|
+
api_request("sendMessage", params) rescue nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def parse_update(update)
|
|
90
|
+
msg = update["message"]
|
|
91
|
+
return nil unless msg && msg["text"]
|
|
92
|
+
|
|
93
|
+
# Skip if we're filtering chat IDs and this isn't allowed
|
|
94
|
+
if @allowed_chat_ids.any? && !@allowed_chat_ids.include?(msg["chat"]["id"])
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sender_name = [msg.dig("from", "first_name"), msg.dig("from", "last_name")]
|
|
99
|
+
.compact.join(" ")
|
|
100
|
+
|
|
101
|
+
Message.new(
|
|
102
|
+
channel_id: channel_id,
|
|
103
|
+
sender: :user,
|
|
104
|
+
content: msg["text"],
|
|
105
|
+
timestamp: Time.at(msg["date"]),
|
|
106
|
+
metadata: {
|
|
107
|
+
chat_id: msg["chat"]["id"],
|
|
108
|
+
message_id: msg["message_id"],
|
|
109
|
+
sender_name: sender_name,
|
|
110
|
+
sender_username: msg.dig("from", "username"),
|
|
111
|
+
sender_id: msg.dig("from", "id")
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def api_request(method, params = {})
|
|
117
|
+
uri = URI("#{API_BASE}/bot#{@bot_token}/#{method}")
|
|
118
|
+
|
|
119
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
120
|
+
http.use_ssl = true
|
|
121
|
+
http.read_timeout = 10
|
|
122
|
+
|
|
123
|
+
request = Net::HTTP::Post.new(uri)
|
|
124
|
+
request["Content-Type"] = "application/json"
|
|
125
|
+
request.body = JSON.generate(params)
|
|
126
|
+
|
|
127
|
+
response = http.request(request)
|
|
128
|
+
parsed = JSON.parse(response.body)
|
|
129
|
+
|
|
130
|
+
unless parsed["ok"]
|
|
131
|
+
raise Error, "Telegram API error: #{parsed["description"]}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
parsed
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|