openclacky 1.0.2 → 1.0.3
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/CHANGELOG.md +16 -1
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor.rb +15 -4
- data/lib/clacky/agent/message_compressor_helper.rb +41 -2
- data/lib/clacky/agent/tool_registry.rb +109 -0
- data/lib/clacky/agent.rb +16 -0
- data/lib/clacky/agent_config.rb +17 -0
- data/lib/clacky/cli.rb +65 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +57 -3
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
- data/lib/clacky/server/channel/channel_manager.rb +91 -0
- data/lib/clacky/server/discover.rb +77 -0
- data/lib/clacky/server/epipe_safe_io.rb +105 -0
- data/lib/clacky/server/http_server.rb +80 -40
- data/lib/clacky/server/server_master.rb +6 -0
- data/lib/clacky/skill.rb +30 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +58 -22
- data/lib/clacky/web/i18n.js +4 -2
- data/lib/clacky/web/sessions.js +29 -17
- metadata +4 -2
|
@@ -4,9 +4,10 @@ description: |
|
|
|
4
4
|
Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
|
|
5
5
|
Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
|
|
6
6
|
Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "channel config",
|
|
7
|
-
"channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor"
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
"channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
|
|
8
|
+
"send message to weixin", "send message to feishu", "send message to wecom".
|
|
9
|
+
Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
|
|
10
|
+
argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
|
|
10
11
|
allowed-tools:
|
|
11
12
|
- Bash
|
|
12
13
|
- Read
|
|
@@ -33,6 +34,7 @@ Configure IM platform channels for openclacky.
|
|
|
33
34
|
| `channel disable feishu/wecom/weixin` | disable |
|
|
34
35
|
| `channel reconfigure` | reconfigure |
|
|
35
36
|
| `channel doctor` | doctor |
|
|
37
|
+
| `send <message> to weixin/feishu/wecom` | send |
|
|
36
38
|
|
|
37
39
|
---
|
|
38
40
|
|
|
@@ -347,6 +349,58 @@ Check each item, report ✅ / ❌ with remediation:
|
|
|
347
349
|
|
|
348
350
|
---
|
|
349
351
|
|
|
352
|
+
## `send`
|
|
353
|
+
|
|
354
|
+
Proactively send a message to a user via an IM channel adapter.
|
|
355
|
+
|
|
356
|
+
### Parse the request
|
|
357
|
+
|
|
358
|
+
Extract two things from the user's instruction:
|
|
359
|
+
- **platform** — one of `weixin`, `feishu`, `wecom`
|
|
360
|
+
- **message** — the text content to send
|
|
361
|
+
|
|
362
|
+
If the platform cannot be inferred, ask the user to clarify.
|
|
363
|
+
|
|
364
|
+
### Step 1 — Resolve target user (optional)
|
|
365
|
+
|
|
366
|
+
If the user specified a `user_id`, use it directly.
|
|
367
|
+
|
|
368
|
+
Otherwise, list known users first:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/users
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- If the list is **empty**: tell the user "No known users for `<platform>`. The target user must send at least one message to the bot before proactive messaging is possible." Stop here.
|
|
375
|
+
- If there is **exactly one** user: use it silently.
|
|
376
|
+
- If there are **multiple** users: show the list and ask which one to send to, unless the user already specified one.
|
|
377
|
+
|
|
378
|
+
### Step 2 — Send the message
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/send \
|
|
382
|
+
-H "Content-Type: application/json" \
|
|
383
|
+
-d '{"message": "<message>", "user_id": "<user_id>"}'
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Response handling:**
|
|
387
|
+
|
|
388
|
+
| HTTP status | Meaning | Action |
|
|
389
|
+
|---|---|---|
|
|
390
|
+
| `200 { ok: true }` | Delivered | Tell user: "✅ Message sent to `<platform>`." |
|
|
391
|
+
| `400` platform not running | Adapter is stopped | Tell user the platform is not running and suggest `channel enable <platform>`. |
|
|
392
|
+
| `400` no context_token | Token missing | Explain: "The bot has no active session token for this user. Ask the user to send any message to the bot first, then retry." |
|
|
393
|
+
| `503` no known users | Nobody has messaged the bot | Same guidance as empty user list above. |
|
|
394
|
+
| Other error | Unexpected | Show the error message from the response body. |
|
|
395
|
+
|
|
396
|
+
### Constraints & notes
|
|
397
|
+
|
|
398
|
+
- **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
|
|
399
|
+
- **Feishu / WeCom**: No token required. As long as the adapter is running and the `user_id` / `chat_id` is valid, the message will be delivered.
|
|
400
|
+
- This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
350
404
|
## Security
|
|
351
405
|
|
|
352
406
|
- Always mask secrets in output (last 4 chars only).
|
|
@@ -393,6 +393,13 @@ module Clacky
|
|
|
393
393
|
@ctx_mutex.synchronize { @context_tokens[user_id] }
|
|
394
394
|
end
|
|
395
395
|
|
|
396
|
+
# Return all user IDs for which we have a cached context_token.
|
|
397
|
+
# Used by ChannelManager#known_users so callers can enumerate
|
|
398
|
+
# users reachable for proactive messaging.
|
|
399
|
+
def context_token_user_ids
|
|
400
|
+
@ctx_mutex.synchronize { @context_tokens.keys.dup }
|
|
401
|
+
end
|
|
402
|
+
|
|
396
403
|
# Split text into ≤2000 Unicode character chunks per iLink protocol recommendation.
|
|
397
404
|
# Priority: split at \n\n, then \n, then space, then hard cut.
|
|
398
405
|
def split_message(text, limit: 2000)
|
|
@@ -77,6 +77,74 @@ module Clacky
|
|
|
77
77
|
@mutex.synchronize { @adapters.map(&:platform_id) }
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Proactively send a message to a user on the given platform.
|
|
81
|
+
#
|
|
82
|
+
# For Weixin (iLink protocol) a context_token is required for every outbound
|
|
83
|
+
# message. This method looks up the most-recently cached token for user_id.
|
|
84
|
+
# If no token is found the message cannot be delivered and nil is returned.
|
|
85
|
+
#
|
|
86
|
+
# For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
|
|
87
|
+
#
|
|
88
|
+
# @param platform [Symbol, String] e.g. :weixin, :feishu, :wecom
|
|
89
|
+
# @param user_id [String] IM user identifier
|
|
90
|
+
# @param message [String] plain-text (or markdown) message to send
|
|
91
|
+
# @return [Hash, nil] adapter result hash, or nil on failure
|
|
92
|
+
def send_to_user(platform, user_id, message)
|
|
93
|
+
platform = platform.to_sym
|
|
94
|
+
adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
|
|
95
|
+
|
|
96
|
+
unless adapter
|
|
97
|
+
Clacky::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Clacky::Logger.info("[ChannelManager] send_to_user :#{platform} → #{user_id}")
|
|
102
|
+
adapter.send_text(user_id, message)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Clacky::Logger.error("[ChannelManager] send_to_user failed: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Return a list of known user IDs for the given platform.
|
|
109
|
+
# Collected from every message that has been processed since the server started.
|
|
110
|
+
# Weixin stores context_tokens keyed by user_id; feishu/wecom track chat_ids
|
|
111
|
+
# via the session binding table in the registry.
|
|
112
|
+
#
|
|
113
|
+
# @param platform [Symbol, String]
|
|
114
|
+
# @return [Array<String>]
|
|
115
|
+
def known_users(platform)
|
|
116
|
+
platform = platform.to_sym
|
|
117
|
+
adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
|
|
118
|
+
return [] unless adapter
|
|
119
|
+
|
|
120
|
+
# Weixin adapter exposes @context_tokens whose keys are user_ids
|
|
121
|
+
if adapter.respond_to?(:context_token_user_ids)
|
|
122
|
+
return adapter.context_token_user_ids
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Fallback: scan session registry for channel_keys matching this platform.
|
|
126
|
+
# Key formats depend on binding_mode:
|
|
127
|
+
# :user → "platform:user:USER_ID"
|
|
128
|
+
# :chat → "platform:chat:CHAT_ID"
|
|
129
|
+
# :chat_user → "platform:chat:CHAT_ID:user:USER_ID"
|
|
130
|
+
#
|
|
131
|
+
# For send_text we need the chat_id (Feishu/WeCom use chat_id as the
|
|
132
|
+
# receive_id for outbound messages), so we extract the chat portion.
|
|
133
|
+
prefix = "#{platform}:"
|
|
134
|
+
ids = []
|
|
135
|
+
@registry.list.each do |summary|
|
|
136
|
+
@registry.with_session(summary[:id]) do |s|
|
|
137
|
+
(s[:channel_keys] || []).each do |key|
|
|
138
|
+
next unless key.start_with?(prefix)
|
|
139
|
+
|
|
140
|
+
remainder = key.sub(prefix, "") # e.g. "chat:OC_ID:user:OU_ID" or "user:UID" or "chat:CID"
|
|
141
|
+
ids << extract_chat_id(remainder)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
ids.compact.uniq
|
|
146
|
+
end
|
|
147
|
+
|
|
80
148
|
# Hot-reload a single platform adapter with updated config.
|
|
81
149
|
# Stops the existing adapter (if running), then starts a new one if enabled.
|
|
82
150
|
# @param platform [Symbol]
|
|
@@ -367,6 +435,29 @@ module Clacky
|
|
|
367
435
|
end
|
|
368
436
|
end
|
|
369
437
|
|
|
438
|
+
# Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
|
|
439
|
+
#
|
|
440
|
+
# Possible formats:
|
|
441
|
+
# "chat:CHAT_ID:user:USER_ID" → CHAT_ID (chat_user mode)
|
|
442
|
+
# "chat:CHAT_ID" → CHAT_ID (chat mode)
|
|
443
|
+
# "user:USER_ID" → USER_ID (user mode — use user_id as fallback)
|
|
444
|
+
#
|
|
445
|
+
# For Feishu/WeCom send_text, the chat_id is what's needed as receive_id.
|
|
446
|
+
private def extract_chat_id(remainder)
|
|
447
|
+
if remainder.start_with?("chat:")
|
|
448
|
+
# "chat:CHAT_ID:user:USER_ID" or "chat:CHAT_ID"
|
|
449
|
+
after_chat = remainder.sub("chat:", "")
|
|
450
|
+
# If there's a ":user:" segment, strip it and everything after
|
|
451
|
+
idx = after_chat.index(":user:")
|
|
452
|
+
idx ? after_chat[0...idx] : after_chat
|
|
453
|
+
elsif remainder.start_with?("user:")
|
|
454
|
+
# user-only mode: no chat_id available, use user_id
|
|
455
|
+
remainder.sub("user:", "")
|
|
456
|
+
else
|
|
457
|
+
remainder
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
370
461
|
def safe_stop_adapter(adapter)
|
|
371
462
|
adapter.stop
|
|
372
463
|
rescue StandardError => e
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module Server
|
|
8
|
+
# Discover locally-running Clacky server(s) by scanning PID files
|
|
9
|
+
# written by Master at /tmp/clacky-master-<port>.pid.
|
|
10
|
+
#
|
|
11
|
+
# Used by the CLI (bare `clacky agent` mode) to auto-detect a sibling
|
|
12
|
+
# server process, so skills that call back into the server (channels,
|
|
13
|
+
# browser, scheduler, etc.) can work without the user manually setting
|
|
14
|
+
# CLACKY_SERVER_HOST / CLACKY_SERVER_PORT.
|
|
15
|
+
#
|
|
16
|
+
# Fast and side-effect free: only reads files and sends signal 0.
|
|
17
|
+
# Does NOT probe the TCP port (avoids false positives from stale files
|
|
18
|
+
# but also avoids noisy connection attempts).
|
|
19
|
+
module Discover
|
|
20
|
+
PID_FILE_GLOB = File.join(Dir.tmpdir, "clacky-master-*.pid").freeze
|
|
21
|
+
PID_FILE_REGEX = /clacky-master-(\d+)\.pid\z/.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Find the first live Clacky server on this machine.
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash, nil] { host: "127.0.0.1", port: Integer, pid: Integer } or nil
|
|
28
|
+
def find_local
|
|
29
|
+
find_all_local.first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find all live Clacky servers on this machine.
|
|
33
|
+
#
|
|
34
|
+
# A PID file is considered "live" when:
|
|
35
|
+
# 1. The filename matches clacky-master-<port>.pid
|
|
36
|
+
# 2. Its contents parse as a positive integer
|
|
37
|
+
# 3. Process.kill(0, pid) confirms the PID is alive
|
|
38
|
+
#
|
|
39
|
+
# Stale PID files (process gone) are silently ignored. We do NOT
|
|
40
|
+
# delete them here — that's the owning server's responsibility.
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<Hash>] sorted by port ascending
|
|
43
|
+
def find_all_local
|
|
44
|
+
Dir.glob(PID_FILE_GLOB).filter_map do |path|
|
|
45
|
+
m = path.match(PID_FILE_REGEX)
|
|
46
|
+
next nil unless m
|
|
47
|
+
|
|
48
|
+
port = m[1].to_i
|
|
49
|
+
next nil if port <= 0
|
|
50
|
+
|
|
51
|
+
pid_str = File.read(path).strip rescue nil
|
|
52
|
+
next nil if pid_str.nil? || pid_str.empty?
|
|
53
|
+
|
|
54
|
+
pid = pid_str.to_i
|
|
55
|
+
next nil if pid <= 0
|
|
56
|
+
|
|
57
|
+
next nil unless process_alive?(pid)
|
|
58
|
+
|
|
59
|
+
{ host: "127.0.0.1", port: port, pid: pid }
|
|
60
|
+
end.sort_by { |e| e[:port] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param pid [Integer]
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def process_alive?(pid)
|
|
66
|
+
Process.kill(0, pid)
|
|
67
|
+
true
|
|
68
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
69
|
+
# ESRCH — no such process; EPERM — process exists but owned by someone else
|
|
70
|
+
# (still technically alive, but we can't safely assume it's "our" server)
|
|
71
|
+
false
|
|
72
|
+
rescue StandardError
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Server
|
|
7
|
+
# EPIPESafeIO — wraps an IO ($stdout / $stderr) so that writes never raise
|
|
8
|
+
# Errno::EPIPE / IOError to the calling code.
|
|
9
|
+
#
|
|
10
|
+
# Why this exists:
|
|
11
|
+
# The server worker process inherits fd 0/1/2 from the Master. If the
|
|
12
|
+
# Master itself was launched in a way where its stdout/stderr eventually
|
|
13
|
+
# becomes a broken pipe (e.g. launched by an installer that exits, or by
|
|
14
|
+
# a GUI/IDE process that closes its end), the worker's first `puts` after
|
|
15
|
+
# that pipe breaks raises Errno::EPIPE. Unhandled, this kills the worker
|
|
16
|
+
# — taking all in-memory sessions, agent loops, and SSE connections down
|
|
17
|
+
# with it, and triggering a crash loop because the new worker inherits
|
|
18
|
+
# the same broken fd.
|
|
19
|
+
#
|
|
20
|
+
# Behavior:
|
|
21
|
+
# - Healthy state: delegates every method to the underlying IO. Users
|
|
22
|
+
# see normal terminal output (banner, request logs, etc.).
|
|
23
|
+
# - First broken-pipe failure: catches Errno::EPIPE / IOError, swaps
|
|
24
|
+
# the underlying IO to /dev/null permanently, and returns silently.
|
|
25
|
+
# Subsequent writes succeed (into /dev/null) so the worker stays alive.
|
|
26
|
+
# - Session state, agent loops, SSE connections all preserved.
|
|
27
|
+
#
|
|
28
|
+
# Scope:
|
|
29
|
+
# We only wrap $stdout / $stderr (the global variables that Kernel#puts,
|
|
30
|
+
# Kernel#print, Kernel#warn, etc. use under the hood). We do NOT touch
|
|
31
|
+
# the STDOUT / STDERR constants — a codebase audit confirmed nothing in
|
|
32
|
+
# Clacky writes to those constants directly (only `STDOUT.flush` which
|
|
33
|
+
# cannot raise EPIPE).
|
|
34
|
+
class EPIPESafeIO < SimpleDelegator
|
|
35
|
+
# Methods that perform writes and may raise Errno::EPIPE.
|
|
36
|
+
# We override each one to rescue and degrade gracefully.
|
|
37
|
+
WRITE_METHODS = %i[write write_nonblock syswrite puts print printf << putc].freeze
|
|
38
|
+
|
|
39
|
+
WRITE_METHODS.each do |m|
|
|
40
|
+
define_method(m) do |*args, **kwargs, &blk|
|
|
41
|
+
if kwargs.empty?
|
|
42
|
+
__getobj__.public_send(m, *args, &blk)
|
|
43
|
+
else
|
|
44
|
+
__getobj__.public_send(m, *args, **kwargs, &blk)
|
|
45
|
+
end
|
|
46
|
+
rescue Errno::EPIPE, IOError => e
|
|
47
|
+
fall_back_to_null!(e)
|
|
48
|
+
# Retry the write into /dev/null so semantics (return value type) stay
|
|
49
|
+
# close to what the caller expects. If even this fails, swallow it —
|
|
50
|
+
# we must not raise from inside a write to $stdout/$stderr.
|
|
51
|
+
begin
|
|
52
|
+
if kwargs.empty?
|
|
53
|
+
__getobj__.public_send(m, *args, &blk)
|
|
54
|
+
else
|
|
55
|
+
__getobj__.public_send(m, *args, **kwargs, &blk)
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Some callers do `$stdout.flush`. Make it safe too.
|
|
64
|
+
def flush
|
|
65
|
+
__getobj__.flush
|
|
66
|
+
rescue Errno::EPIPE, IOError => e
|
|
67
|
+
fall_back_to_null!(e)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether this wrapper has already fallen back to /dev/null.
|
|
72
|
+
# Useful for tests and diagnostics.
|
|
73
|
+
def fell_back?
|
|
74
|
+
@fell_back == true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private def fall_back_to_null!(error)
|
|
78
|
+
return if @fell_back
|
|
79
|
+
|
|
80
|
+
@fell_back = true
|
|
81
|
+
begin
|
|
82
|
+
# Best-effort: try to log once via Clacky::Logger if available.
|
|
83
|
+
# Wrapped in rescue because Logger itself might be mid-init.
|
|
84
|
+
if defined?(Clacky::Logger)
|
|
85
|
+
Clacky::Logger.warn(
|
|
86
|
+
"[EPIPESafeIO] Underlying IO broken (#{error.class}: #{error.message}); " \
|
|
87
|
+
"falling back to /dev/null. Worker stays alive."
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError
|
|
91
|
+
# ignore
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
null = File.open(File::NULL, "w")
|
|
96
|
+
null.sync = true
|
|
97
|
+
__setobj__(null)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
# If even opening /dev/null fails, leave the original object — at
|
|
100
|
+
# worst the next write raises again and we rescue again.
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -203,15 +203,6 @@ module Clacky
|
|
|
203
203
|
|
|
204
204
|
Clacky::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
|
|
205
205
|
|
|
206
|
-
# In standalone mode (no master), kill any stale server and manage our own PID file.
|
|
207
|
-
# In worker mode the master owns the PID file; we just skip this block.
|
|
208
|
-
if @inherited_socket.nil?
|
|
209
|
-
kill_existing_server(@port)
|
|
210
|
-
pid_file = File.join(Dir.tmpdir, "clacky-server-#{@port}.pid")
|
|
211
|
-
File.write(pid_file, Process.pid.to_s)
|
|
212
|
-
at_exit { File.delete(pid_file) if File.exist?(pid_file) }
|
|
213
|
-
end
|
|
214
|
-
|
|
215
206
|
# Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
|
|
216
207
|
# so they can call back into the server without hardcoding the port,
|
|
217
208
|
# and use the correct product name without re-reading brand.yml.
|
|
@@ -412,7 +403,13 @@ module Clacky
|
|
|
412
403
|
when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
413
404
|
when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
|
|
414
405
|
else
|
|
415
|
-
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/
|
|
406
|
+
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
|
|
407
|
+
platform = path.sub("/api/channels/", "").sub("/send", "")
|
|
408
|
+
api_send_channel_message(platform, req, res)
|
|
409
|
+
elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
|
|
410
|
+
platform = path.sub("/api/channels/", "").sub("/users", "")
|
|
411
|
+
api_list_channel_users(platform, res)
|
|
412
|
+
elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
|
|
416
413
|
platform = path.sub("/api/channels/", "").sub("/test", "")
|
|
417
414
|
api_test_channel(platform, req, res)
|
|
418
415
|
elsif method == "POST" && path.start_with?("/api/channels/")
|
|
@@ -1439,6 +1436,79 @@ module Clacky
|
|
|
1439
1436
|
json_response(res, 200, { channels: platforms })
|
|
1440
1437
|
end
|
|
1441
1438
|
|
|
1439
|
+
# POST /api/channels/:platform/send
|
|
1440
|
+
# Proactively send a message to a user via the given IM platform.
|
|
1441
|
+
#
|
|
1442
|
+
# Body:
|
|
1443
|
+
# { "message": "hello", # required
|
|
1444
|
+
# "user_id": "some_user_id" } # optional — defaults to most-recently active user
|
|
1445
|
+
#
|
|
1446
|
+
# Response:
|
|
1447
|
+
# 200 { ok: true }
|
|
1448
|
+
# 400 { ok: false, error: "..." } — missing/invalid params or platform not running
|
|
1449
|
+
# 503 { ok: false, error: "..." } — no known users (nobody has messaged the bot yet)
|
|
1450
|
+
#
|
|
1451
|
+
# Constraints:
|
|
1452
|
+
# - The platform adapter must be running (channel must be enabled + connected).
|
|
1453
|
+
# - For Weixin (iLink protocol), a context_token is required per message. This is
|
|
1454
|
+
# automatically looked up from the in-memory cache populated by inbound messages.
|
|
1455
|
+
# If no token exists for the target user (i.e. the user has never messaged the bot
|
|
1456
|
+
# in this server session), the message cannot be delivered.
|
|
1457
|
+
def api_send_channel_message(platform, req, res)
|
|
1458
|
+
platform = platform.to_sym
|
|
1459
|
+
body = parse_json_body(req)
|
|
1460
|
+
message = body["message"].to_s.strip
|
|
1461
|
+
|
|
1462
|
+
if message.empty?
|
|
1463
|
+
json_response(res, 400, { ok: false, error: "message is required" })
|
|
1464
|
+
return
|
|
1465
|
+
end
|
|
1466
|
+
|
|
1467
|
+
# Resolve target user_id
|
|
1468
|
+
user_id = body["user_id"].to_s.strip
|
|
1469
|
+
if user_id.empty?
|
|
1470
|
+
# Default to the most-recently active user for this platform
|
|
1471
|
+
known = @channel_manager.known_users(platform)
|
|
1472
|
+
if known.empty?
|
|
1473
|
+
json_response(res, 503, {
|
|
1474
|
+
ok: false,
|
|
1475
|
+
error: "No known users for :#{platform}. The user must send a message to the bot first."
|
|
1476
|
+
})
|
|
1477
|
+
return
|
|
1478
|
+
end
|
|
1479
|
+
user_id = known.last
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
result = @channel_manager.send_to_user(platform, user_id, message)
|
|
1483
|
+
if result.nil?
|
|
1484
|
+
json_response(res, 400, {
|
|
1485
|
+
ok: false,
|
|
1486
|
+
error: "Failed to send message. The :#{platform} adapter may not be running, or no context_token is available for user #{user_id}."
|
|
1487
|
+
})
|
|
1488
|
+
else
|
|
1489
|
+
json_response(res, 200, { ok: true, platform: platform, user_id: user_id })
|
|
1490
|
+
end
|
|
1491
|
+
rescue StandardError => e
|
|
1492
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
# GET /api/channels/:platform/users
|
|
1496
|
+
# Returns the list of known user IDs for the given platform.
|
|
1497
|
+
# These are users who have sent at least one message to the bot in this server session.
|
|
1498
|
+
#
|
|
1499
|
+
# For Weixin: returns users with a cached context_token (required for proactive messaging).
|
|
1500
|
+
# For Feishu / WeCom: returns user IDs extracted from channel session bindings.
|
|
1501
|
+
#
|
|
1502
|
+
# Response:
|
|
1503
|
+
# 200 { users: ["uid1", "uid2", ...] }
|
|
1504
|
+
def api_list_channel_users(platform, res)
|
|
1505
|
+
platform = platform.to_sym
|
|
1506
|
+
users = @channel_manager.known_users(platform)
|
|
1507
|
+
json_response(res, 200, { platform: platform, users: users })
|
|
1508
|
+
rescue StandardError => e
|
|
1509
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1510
|
+
end
|
|
1511
|
+
|
|
1442
1512
|
# POST /api/upload
|
|
1443
1513
|
# Accepts a multipart/form-data file upload (field name: "file").
|
|
1444
1514
|
# Runs the file through FileProcessor: saves original + generates structured
|
|
@@ -3648,36 +3718,6 @@ module Clacky
|
|
|
3648
3718
|
res.body = "Not Found"
|
|
3649
3719
|
end
|
|
3650
3720
|
|
|
3651
|
-
# Stop any previously running server on the given port via its PID file.
|
|
3652
|
-
private def kill_existing_server(port)
|
|
3653
|
-
pid_file = File.join(Dir.tmpdir, "clacky-server-#{port}.pid")
|
|
3654
|
-
return unless File.exist?(pid_file)
|
|
3655
|
-
|
|
3656
|
-
pid = File.read(pid_file).strip.to_i
|
|
3657
|
-
return if pid <= 0
|
|
3658
|
-
# After exec-restart, the new process inherits the same PID as the old one.
|
|
3659
|
-
# Skip sending TERM to ourselves — we are already the new server.
|
|
3660
|
-
if pid == Process.pid
|
|
3661
|
-
Clacky::Logger.info("[Server] exec-restart detected (PID=#{pid}), skipping self-kill.")
|
|
3662
|
-
return
|
|
3663
|
-
end
|
|
3664
|
-
|
|
3665
|
-
begin
|
|
3666
|
-
Process.kill("TERM", pid)
|
|
3667
|
-
Clacky::Logger.info("[Server] Stopped existing server (PID=#{pid}) on port #{port}.")
|
|
3668
|
-
puts "Stopped existing server (PID: #{pid}) on port #{port}."
|
|
3669
|
-
# Give it a moment to release the port
|
|
3670
|
-
sleep 0.5
|
|
3671
|
-
rescue Errno::ESRCH
|
|
3672
|
-
Clacky::Logger.info("[Server] Existing server PID=#{pid} already gone.")
|
|
3673
|
-
rescue Errno::EPERM
|
|
3674
|
-
Clacky::Logger.warn("[Server] Could not stop existing server (PID=#{pid}) — permission denied.")
|
|
3675
|
-
puts "Could not stop existing server (PID: #{pid}) — permission denied."
|
|
3676
|
-
ensure
|
|
3677
|
-
File.delete(pid_file) if File.exist?(pid_file)
|
|
3678
|
-
end
|
|
3679
|
-
end
|
|
3680
|
-
|
|
3681
3721
|
# ── Inner classes ─────────────────────────────────────────────────────────
|
|
3682
3722
|
|
|
3683
3723
|
# Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
|
|
@@ -143,10 +143,16 @@ module Clacky
|
|
|
143
143
|
|
|
144
144
|
Clacky::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
|
|
145
145
|
Clacky::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
|
|
146
|
+
|
|
146
147
|
# pgroup: 0 puts worker in its own process group.
|
|
147
148
|
# This lets Master send TERM/KILL to the entire group (-pid) on shutdown,
|
|
148
149
|
# ensuring grandchildren (e.g. chrome-devtools-mcp node process) are also
|
|
149
150
|
# cleaned up even if the worker is force-killed before its shutdown_proc runs.
|
|
151
|
+
#
|
|
152
|
+
# NOTE on stdio: we deliberately let the worker inherit Master's fd 0/1/2
|
|
153
|
+
# so users see startup banner / request logs in their terminal. Protection
|
|
154
|
+
# against Errno::EPIPE on broken parent stdout is installed inside the
|
|
155
|
+
# worker itself (see cli.rb worker entry — EPIPESafeIO wrapper).
|
|
150
156
|
pid = spawn(env, ruby, script, *worker_argv, pgroup: 0)
|
|
151
157
|
Clacky::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid} pgroup=#{pid}")
|
|
152
158
|
pid
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -287,6 +287,36 @@ module Clacky
|
|
|
287
287
|
end
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
# Environment hint: if the skill references ${CLACKY_SERVER_HOST/PORT} but
|
|
291
|
+
# those vars were not injected (bare-CLI mode without a running server),
|
|
292
|
+
# the `${...}` placeholders will survive expansion as literal text. In that
|
|
293
|
+
# case append a non-fatal note so the LLM knows the skill's HTTP callbacks
|
|
294
|
+
# will not work, without blocking the skill entirely (the user may still
|
|
295
|
+
# want to read instructions, explore files, etc.).
|
|
296
|
+
if processed_content.match?(/\$\{CLACKY_SERVER_(HOST|PORT)\}/)
|
|
297
|
+
processed_content += <<~HINT
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
> ⚠️ **Environment note (auto-injected)**: this skill calls back into the
|
|
303
|
+
> Clacky HTTP server (via `${CLACKY_SERVER_HOST}` / `${CLACKY_SERVER_PORT}`),
|
|
304
|
+
> but those variables are **not set** in the current process. That means
|
|
305
|
+
> no local Clacky server was detected.
|
|
306
|
+
>
|
|
307
|
+
> Any `curl http://${CLACKY_SERVER_HOST}:...` command in the steps above
|
|
308
|
+
> will fail with a DNS/connection error. Before running those steps you
|
|
309
|
+
> should either:
|
|
310
|
+
>
|
|
311
|
+
> 1. Ask the user to start the server in another terminal: `clacky server`
|
|
312
|
+
> (then retry — the CLI auto-detects it via `/tmp/clacky-master-*.pid`), or
|
|
313
|
+
> 2. If the task can be completed without the server API, skip those steps
|
|
314
|
+
> and tell the user which parts require the server.
|
|
315
|
+
>
|
|
316
|
+
> This is an informational hint, not an error. Proceed with judgment.
|
|
317
|
+
HINT
|
|
318
|
+
end
|
|
319
|
+
|
|
290
320
|
processed_content
|
|
291
321
|
end
|
|
292
322
|
|
data/lib/clacky/version.rb
CHANGED