openclacky 1.0.4 → 1.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32e7797fb57aaa140bb53070def7de2588a6a98816cc0b7ce7ed4457385f5ce1
4
- data.tar.gz: 8b6ad8e808f725debfae7803fadc527ce48da56660e7242cebddee8aa93ffab1
3
+ metadata.gz: 597ea8fc84ca69641339dbacf724e114d4fc7c5c43bcafb81767bcb83da89edd
4
+ data.tar.gz: 23f576f36023978f37dfe68e12ec942e3cedd591bec1cd098a5829a8d9057a24
5
5
  SHA512:
6
- metadata.gz: 425fbfa12f4d2e2cc10c33a325406a5ff8df8db54c8888656b49230730f67304f2a35b6b4541d033a59a315448326d42b0dd3b886055dce104796df4fdc7bf30
7
- data.tar.gz: f24a8c62187e47196330fcd28550101bd7074f33387c3df297210c69f9c211f37178ec20a8b66bce829a1a8dc73d8e11a074f5611b1a00336c5e8c655556c3fb
6
+ metadata.gz: 0fed807f559eee7d3df5508bbef61d45f75a0943c5d76ac17cbf9a334bfc75be729ad6ab03485211c532de186b1f41aa27c54267f2e36efa7c191699e3fcca2e
7
+ data.tar.gz: c8943553c4cd0f721515e0ffad5aa554dcdffc0305aca6d7ac403a02762b59db63273415d452965767b0bd2157ab835c946b081e99b0ce5a8574a3bf622e6aa1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.5] - 2026-05-12
9
+
10
+ ### Added
11
+ - **Telegram channel adapter.** New IM channel adapter that connects openclacky to Telegram via the Bot API. Setup is just a bot token from @BotFather — no browser automation, no QR. Mirrors the existing Feishu / WeCom / Weixin contract: HTTPS long-poll inbound, `sendMessage` / `sendPhoto` / `sendDocument` outbound, photo + document download routed through the standard FileProcessor + vision pipeline, group `@-mention` filtering and `allowed_users` whitelist. `base_url` is configurable to support self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) for networks where `api.telegram.org` is unreachable. Frontend Channels panel, `channel-setup` skill, English/Chinese i18n, and `app.css` logo class added. 32 new specs in `spec/clacky/server/channel/adapters/telegram/`.
12
+ - **Discord channel adapter.** Full Discord integration via REST API + Gateway (WebSocket), with channel-setup support, Web UI Channels panel entry, and i18n strings. Connect Clacky to Discord servers for bot interactions through slash commands and message events.
13
+ - **OpenRouter curated model list.** The OpenRouter provider now ships with a curated dropdown of mainstream Claude and GPT models (Sonnet, Opus, Haiku, GPT-5.5/5.4), so users can pick from the list instead of typing model IDs manually. Full catalogue still accessible by typing any model ID.
14
+ - **OpenRouter lite model pairing.** Subagents on OpenRouter now automatically get a sensible cheap/fast sidekick — Claude family pairs with Haiku, GPT family pairs with the mini variant — matching the behavior already available on the native OpenAI and OpenClacky providers.
15
+ - **MiMo 2.5 Pro (Xiaomi) model support.** Added `mimo-v2.5-pro` to the MiMo provider preset alongside existing MiMo models.
16
+ - **AI key setup guide link.** New users and those configuring API keys now see a "New to AI keys? See the guide →" link on both onboarding and settings pages, pointing to the official documentation.
17
+
18
+ ### Improved
19
+ - **Default model upgraded to claude-sonnet-4-6.** The OpenClacky provider now defaults to the latest Claude Sonnet model for better performance out of the box.
20
+
21
+ ### Fixed
22
+ - **Linux server restart stability.** Fixed an inherited socket cleanup bug where WEBrick's shutdown would propagate `SHUT_RDWR` to the shared kernel socket, breaking subsequent `accept()` calls on Linux. The server now detaches inherited sockets before shutdown so worker restarts work reliably.
23
+ - **Upgrade failure recovery UI.** When an in-app upgrade restart fails, the UI now shows both tray icon and CLI recovery paths (`gem update ...`) instead of leaving users stranded. Also added branded CLI command info to the version check API for white-label builds.
24
+
8
25
  ## [1.0.4] - 2026-05-11
9
26
 
10
27
  ### Added
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: channel-setup
3
3
  description: |
4
- Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
4
+ Configure IM platform channels (Feishu, WeCom, Weixin, Discord, Telegram) for openclacky.
5
5
  Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
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
- "send message to weixin", "send message to feishu", "send message to wecom".
6
+ Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "setup discord", "setup telegram",
7
+ "channel config", "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
8
+ "send message to weixin", "send message to feishu", "send message to wecom", "send message to discord", "send message to telegram".
9
9
  Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
10
10
  argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
11
11
  allowed-tools:
@@ -28,13 +28,13 @@ Configure IM platform channels for openclacky.
28
28
 
29
29
  | User says | Subcommand |
30
30
  |---|---|
31
- | `channel setup`, `setup feishu`, `setup wecom`, `setup weixin`, `setup wechat` | setup |
31
+ | `channel setup`, `setup feishu`, `setup wecom`, `setup weixin`, `setup wechat`, `setup discord`, `setup telegram` | setup |
32
32
  | `channel status` | status |
33
- | `channel enable feishu/wecom/weixin` | enable |
34
- | `channel disable feishu/wecom/weixin` | disable |
33
+ | `channel enable feishu/wecom/weixin/discord/telegram` | enable |
34
+ | `channel disable feishu/wecom/weixin/discord/telegram` | disable |
35
35
  | `channel reconfigure` | reconfigure |
36
36
  | `channel doctor` | doctor |
37
- | `send <message> to weixin/feishu/wecom` | send |
37
+ | `send <message> to weixin/feishu/wecom/discord/telegram` | send |
38
38
 
39
39
  ---
40
40
 
@@ -51,7 +51,9 @@ Response shape (example):
51
51
  {"channels":[
52
52
  {"platform":"feishu","enabled":true,"running":true,"has_config":true,"app_id":"cli_xxx","domain":"https://open.feishu.cn","allowed_users":[]},
53
53
  {"platform":"wecom","enabled":false,"running":false,"has_config":false,"bot_id":""},
54
- {"platform":"weixin","enabled":true,"running":true,"has_config":true,"has_token":true,"base_url":"https://ilinkai.weixin.qq.com","allowed_users":[]}
54
+ {"platform":"weixin","enabled":true,"running":true,"has_config":true,"has_token":true,"base_url":"https://ilinkai.weixin.qq.com","allowed_users":[]},
55
+ {"platform":"discord","enabled":true,"running":true,"has_config":true,"has_token":true,"allowed_users":[]}
56
+ {"platform":"telegram","enabled":true,"running":true,"has_config":true,"has_token":true,"base_url":"https://api.telegram.org","parse_mode":"Markdown","allowed_users":[]}
55
57
  ]}
56
58
  ```
57
59
 
@@ -64,12 +66,16 @@ Platform Enabled Running Details
64
66
  feishu ✅ yes ✅ yes app_id: cli_xxx...
65
67
  wecom ❌ no ❌ no (not configured)
66
68
  weixin ✅ yes ✅ yes has_token: true
69
+ discord ✅ yes ✅ yes has_token: true
70
+ telegram ✅ yes ✅ yes has_token: true
67
71
  ─────────────────────────────────────────────────────
68
72
  ```
69
73
 
70
74
  - Feishu: show `app_id` (truncated to 12 chars)
71
75
  - WeCom: show `bot_id` if present
72
76
  - Weixin: show `has_token: true/false` (token value is never displayed)
77
+ - Discord: show `has_token: true/false` (token value is never displayed)
78
+ - Telegram: show `has_token: true/false` (bot token is never displayed)
73
79
 
74
80
  If the API is unreachable or returns an empty list: "No channels configured yet. Run `/channel-setup setup` to get started."
75
81
 
@@ -83,6 +89,8 @@ Ask:
83
89
  > 1. Feishu
84
90
  > 2. WeCom (Enterprise WeChat)
85
91
  > 3. Weixin (Personal WeChat via iLink QR login)
92
+ > 4. Discord
93
+ > 5. Telegram (Bot API)
86
94
 
87
95
  ---
88
96
 
@@ -344,6 +352,105 @@ Tell the user while waiting:
344
352
 
345
353
  ---
346
354
 
355
+ ### Discord setup
356
+
357
+ Discord requires manual portal interaction (hCaptcha gates Application creation). The browser just navigates the user to the portal; the user clicks through and pastes the bot token + app id back.
358
+
359
+ #### Step 1 — Open the developer portal
360
+
361
+ Get the portal URL from the script and open it in the browser:
362
+
363
+ ```bash
364
+ PORTAL_URL=$(ruby "SKILL_DIR/discord_setup.rb" --portal-url)
365
+ ```
366
+
367
+ Open it: `browser(action="navigate", url="<PORTAL_URL>")`. If the browser tool is not configured, invoke `browser-setup` first, then retry.
368
+
369
+ #### Step 2 — Guide the user through the portal (one round-trip)
370
+
371
+ Tell the user **all** of the following in a single message, then call `request_user_feedback` to collect the values in one reply:
372
+
373
+ > In the Discord Developer Portal I just opened:
374
+ >
375
+ > 1. Click **New Application** (top-right). Name it whatever you like (e.g. "Open Clacky"), check the ToS box, click **Create**.
376
+ > 2. In the left nav click **Bot**.
377
+ > 3. Scroll down to **Privileged Gateway Intents** and turn on **MESSAGE CONTENT INTENT**, then click **Save Changes**.
378
+ > 4. Scroll up, click **Reset Token** → **Yes, do it!**. Click **Copy** to copy the bot token. (This is the only time the token is shown — don't navigate away before copying.)
379
+ > 5. In the left nav click **General Information**. Copy the **Application ID**.
380
+ >
381
+ > Paste both values back here in this format (one line):
382
+ >
383
+ > `token=YOUR_BOT_TOKEN app_id=YOUR_APPLICATION_ID`
384
+
385
+ If the user is chatting in a non-English language, append the localized label in parens after each bolded English button name (e.g. `**Bot**(机器人)`). The English label stays primary — it's what they physically click in the portal.
386
+
387
+ Use `request_user_feedback` to collect the reply. Parse with tolerant regex (`token=\S+`, `app_id=\d+`).
388
+
389
+ If the reply is malformed (missing either field), apologise briefly and ask again with the exact same format reminder. Up to 3 retries; after that, surface the original message and stop.
390
+
391
+ #### Step 3 — Validate, save, invite, wait
392
+
393
+ 1. Validate the token and save credentials:
394
+ ```bash
395
+ ruby "SKILL_DIR/discord_setup.rb" --validate "<BOT_TOKEN>"
396
+ ```
397
+ On success the script prints `{"bot_id":"...","username":"..."}` and the adapter starts.
398
+
399
+ 2. Generate the invite URL using the application id from Step 2:
400
+ ```bash
401
+ ruby "SKILL_DIR/discord_setup.rb" --invite-url "<APP_ID>"
402
+ ```
403
+ Open it: `browser(action="navigate", url="<INVITE_URL>")`. Tell the user:
404
+ > Pick your server from the dropdown → **Continue** → **Authorize**. I'll detect when the bot joins.
405
+ >
406
+ > If the dropdown is empty, you don't have a server yet — open <https://discord.com/channels/@me>, click **Add a Server** (the **+** button on the left sidebar) → **Create My Own** → **For me and my friends** → name it → **Create**, then re-open the invite link.
407
+
408
+ 3. Wait for the bot to join a guild (long-poll, 10 min timeout). Run with `timeout: 620`:
409
+ ```bash
410
+ ruby "SKILL_DIR/discord_setup.rb" --watch-guild
411
+ ```
412
+ On exit 0: "✅ Discord channel configured! Bot is in `<guild_name>`. Mention it or DM it from any channel."
413
+ On timeout: offer to re-open the invite URL — the bot token stays valid.
414
+
415
+ ### Telegram setup (Bot API)
416
+
417
+ Telegram setup is by far the simplest — no browser automation, no QR. The user creates a bot via @BotFather and pastes the token here.
418
+
419
+ #### Step 1 — Create a bot via @BotFather
420
+
421
+ Tell the user:
422
+
423
+ > Open Telegram and start a chat with **@BotFather** (https://t.me/BotFather). Send `/newbot`, choose a display name and a username ending in `bot`. BotFather will reply with an HTTP API token that looks like `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`. Paste the token here.
424
+ >
425
+ > Optional: if your network blocks `api.telegram.org`, also tell me the base URL of your self-hosted Bot API server (e.g. `https://my-tg-proxy.example.com`). Otherwise leave it blank.
426
+
427
+ Wait for the user's reply. Parse the token (matches `^\d+:[\w-]{30,}$`).
428
+
429
+ #### Step 2 — Save credentials and validate
430
+
431
+ Call the server API. It calls `getMe` against the Bot API to validate the token before persisting:
432
+
433
+ ```bash
434
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/telegram \
435
+ -H "Content-Type: application/json" \
436
+ -d '{"bot_token":"<TOKEN>","base_url":"<BASE_URL_OR_OMIT>"}'
437
+ ```
438
+
439
+ - `200 { "ok": true }` — token validated and saved. The adapter starts long-polling immediately.
440
+ - `422 { "ok": false, "error": "..." }` — show the error (commonly "Unauthorized" → wrong token) and offer to retry.
441
+
442
+ On success:
443
+
444
+ > ✅ Telegram channel configured. Open your bot in Telegram and send any message to start chatting. In group chats, the bot only replies when you @-mention it.
445
+
446
+ #### Notes
447
+
448
+ - **Group chats**: Telegram bots respond only when @-mentioned or directly replied to (matches Feishu behaviour). For unrestricted reading in groups, the bot owner must disable "Group Privacy" in @BotFather (`/mybots → Bot Settings → Group Privacy → Turn off`), but `@-mention only` is recommended to avoid spam.
449
+ - **Self-hosted Bot API**: set `base_url` when `api.telegram.org` is unreachable. See https://github.com/tdlib/telegram-bot-api for the official self-hosted server.
450
+ - **`allowed_users`**: restrict which Telegram user IDs the bot will respond to. Find a user's numeric ID by messaging @userinfobot.
451
+
452
+ ---
453
+
347
454
  ## `enable`
348
455
 
349
456
  Call the server API to re-enable the platform (this reads from disk, sets enabled, saves, and hot-reloads):
@@ -391,15 +498,30 @@ Check each item, report ✅ / ❌ with remediation:
391
498
  - Feishu: `app_id`, `app_secret` present and non-empty
392
499
  - WeCom: `bot_id`, `secret` present and non-empty
393
500
  - Weixin: `token` present and non-empty in `channels.yml`
501
+ - Discord: `bot_token` present and non-empty in `channels.yml`
502
+ - Telegram: `bot_token` present and non-empty
394
503
  3. **Feishu credentials** (if enabled) — run the token API call, check `code=0`.
395
504
  4. **Weixin token** (if enabled) — call `GET /api/channels` and check `has_token: true` for the weixin entry.
396
- 5. **WeCom credentials** (if enabled) — search today's log:
505
+ 5. **Telegram credentials** (if enabled) — call `getMe` against the Bot API:
506
+ ```bash
507
+ BOT_TOKEN=$(ruby -ryaml -e 'puts (YAML.load_file(File.expand_path("~/.clacky/channels.yml"))["channels"]["telegram"]["bot_token"] rescue "")')
508
+ BASE_URL=$(ruby -ryaml -e 'puts (YAML.load_file(File.expand_path("~/.clacky/channels.yml"))["channels"]["telegram"]["base_url"] || "https://api.telegram.org" rescue "https://api.telegram.org")')
509
+ curl -s "$BASE_URL/bot$BOT_TOKEN/getMe" | grep -q '"ok":true' && echo "✅ Telegram OK" || echo "❌ Telegram credentials rejected by getMe"
510
+ ```
511
+ 6. **WeCom credentials** (if enabled) — search today's log:
397
512
  ```bash
398
513
  grep -iE "wecom adapter loop started|WeCom authentication failed|WeCom WS error response|WecomAdapter" \
399
514
  ~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
400
515
  ```
401
516
  - `WeCom authentication failed` or non-zero errcode → ❌ "WeCom credentials incorrect"
402
517
  - `adapter loop started` with no auth error → ✅
518
+ 6. **Discord credentials** (if enabled) — call `GET /api/channels` and check `has_token: true`. Search today's log:
519
+ ```bash
520
+ grep -iE "DiscordAdapter|discord-gateway|/users/@me failed" \
521
+ ~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
522
+ ```
523
+ - `/users/@me failed` → ❌ "Discord token invalid or revoked — re-run setup"
524
+ - `authenticated as` with no error → ✅
403
525
 
404
526
  ---
405
527
 
@@ -410,7 +532,7 @@ Proactively send a message to a user via an IM channel adapter.
410
532
  ### Parse the request
411
533
 
412
534
  Extract two things from the user's instruction:
413
- - **platform** — one of `weixin`, `feishu`, `wecom`
535
+ - **platform** — one of `weixin`, `feishu`, `wecom`, `discord`, `telegram`
414
536
  - **message** — the text content to send
415
537
 
416
538
  If the platform cannot be inferred, ask the user to clarify.
@@ -450,7 +572,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/
450
572
  ### Constraints & notes
451
573
 
452
574
  - **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.
453
- - **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.
575
+ - **Feishu / WeCom / Discord / Telegram**: No per-message token required. As long as the adapter is running and the `user_id` / `chat_id` (or Discord channel/user id) is valid, the message will be delivered. For Telegram specifically, the `user_id` must be a Telegram chat_id that the bot can write to — the user must have sent at least one message to the bot first.
454
576
  - 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.
455
577
 
456
578
  ---
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # discord_setup.rb — Discord channel setup helper.
5
+ #
6
+ # Discord's developer portal requires manual interaction (hCaptcha + private API), so the
7
+ # Agent uses the browser only as a container — it navigates to the portal and the user
8
+ # creates the App manually, then pastes back the bot token and application id. This
9
+ # script handles everything a shell can do: emit the portal URL, validate the token
10
+ # against /users/@me, save to the clacky server, generate the OAuth2 invite URL, and
11
+ # poll until the bot is in at least one guild.
12
+ #
13
+ # Modes:
14
+ # --portal-url Print the Discord developer portal URL (stdout, single line)
15
+ # --validate <token> Validate bot_token via /users/@me, then POST to server
16
+ # --invite-url <client_id> Print the OAuth2 invite URL (stdout, single line)
17
+ # --watch-guild Long-poll /users/@me/guilds via the saved token
18
+ # until at least one guild appears (or timeout)
19
+ # --bot-info <token> Print {id, username} JSON for an unsaved token
20
+ #
21
+ # Environment:
22
+ # CLACKY_SERVER_HOST default 127.0.0.1
23
+ # CLACKY_SERVER_PORT default 7070
24
+
25
+ require "json"
26
+ require "net/http"
27
+ require "net/https"
28
+ require "uri"
29
+ require "openssl"
30
+ require "cgi"
31
+ require "yaml"
32
+
33
+ DISCORD_API_BASE = "https://discord.com/api/v10"
34
+ DISCORD_OAUTH_BASE = "https://discord.com/oauth2/authorize"
35
+ DISCORD_PORTAL_URL = "https://discord.com/developers/applications"
36
+ DEFAULT_BOT_PERMS = "274877990912"
37
+ DEFAULT_BOT_SCOPES = "bot applications.commands"
38
+ WATCH_GUILD_DEADLINE = 10 * 60
39
+ WATCH_GUILD_INTERVAL = 3
40
+ USER_AGENT = "DiscordBot (https://github.com/clackyai/openclacky, 1.0)"
41
+
42
+ CLACKY_SERVER_URL = begin
43
+ host = ENV.fetch("CLACKY_SERVER_HOST", "127.0.0.1")
44
+ port = ENV.fetch("CLACKY_SERVER_PORT", "7070")
45
+ "http://#{host}:#{port}"
46
+ end
47
+
48
+ def step(msg); $stderr.puts("[discord-setup] #{msg}"); end
49
+ def ok(msg); $stderr.puts("[discord-setup] #{msg}"); end
50
+ def warn!(msg); $stderr.puts("[discord-setup] #{msg}"); end
51
+
52
+ def fail!(msg, json: false)
53
+ if json
54
+ $stdout.puts(JSON.generate({ error: msg }))
55
+ else
56
+ $stderr.puts("[discord-setup] #{msg}")
57
+ end
58
+ exit 1
59
+ end
60
+
61
+ def discord_get(path, bot_token:, timeout: 15)
62
+ uri = URI("#{DISCORD_API_BASE}#{path}")
63
+ http = Net::HTTP.new(uri.host, uri.port)
64
+ http.use_ssl = true
65
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
66
+ http.read_timeout = timeout
67
+ http.open_timeout = 10
68
+
69
+ req = Net::HTTP::Get.new(uri.request_uri)
70
+ req["Authorization"] = "Bot #{bot_token}"
71
+ req["User-Agent"] = USER_AGENT
72
+ req["Accept"] = "application/json"
73
+
74
+ res = http.request(req)
75
+ body = res.body.to_s
76
+ parsed = (JSON.parse(body) rescue nil)
77
+
78
+ unless res.is_a?(Net::HTTPSuccess)
79
+ msg = parsed.is_a?(Hash) ? (parsed["message"] || body.slice(0, 200)) : body.slice(0, 200)
80
+ raise "Discord HTTP #{res.code} #{path}: #{msg}"
81
+ end
82
+ parsed
83
+ end
84
+
85
+ def saved_bot_token
86
+ yml_path = File.expand_path("~/.clacky/channels.yml")
87
+ return nil unless File.exist?(yml_path)
88
+ data = YAML.safe_load_file(yml_path, permitted_classes: [Symbol], aliases: true) rescue nil
89
+ data&.dig("channels", "discord", "bot_token") || data&.dig(:channels, :discord, :bot_token)
90
+ end
91
+
92
+ def save_to_server(bot_token:)
93
+ uri = URI("#{CLACKY_SERVER_URL}/api/channels/discord")
94
+ body = JSON.generate({ bot_token: bot_token })
95
+
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.read_timeout = 30
98
+ http.open_timeout = 5
99
+
100
+ req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
101
+ req.body = body
102
+
103
+ res = http.request(req)
104
+ data = JSON.parse(res.body) rescue {}
105
+
106
+ unless res.is_a?(Net::HTTPSuccess) && data["ok"]
107
+ fail!("Failed to save Discord config: #{data["error"] || res.body.slice(0, 200)}")
108
+ end
109
+ end
110
+
111
+ mode_idx = ARGV.index { |a| a.start_with?("--") }
112
+ mode = mode_idx ? ARGV[mode_idx] : nil
113
+ arg = mode_idx ? ARGV[mode_idx + 1] : nil
114
+
115
+ case mode
116
+ when "--portal-url"
117
+ $stdout.puts(DISCORD_PORTAL_URL)
118
+ exit 0
119
+
120
+ when "--validate"
121
+ fail!("--validate requires <bot_token>") if arg.to_s.strip.empty?
122
+ bot_token = arg.strip
123
+ step("Validating bot token against Discord API...")
124
+ begin
125
+ me = discord_get("/users/@me", bot_token: bot_token)
126
+ rescue => e
127
+ fail!("Token validation failed: #{e.message}")
128
+ end
129
+
130
+ bot_id = me["id"].to_s
131
+ username = me["username"].to_s
132
+ fail!("Empty bot id from /users/@me") if bot_id.empty?
133
+
134
+ ok("Authenticated as #{username} (id=#{bot_id})")
135
+ step("Saving credentials via clacky server...")
136
+ save_to_server(bot_token: bot_token)
137
+ ok("Discord channel configured")
138
+
139
+ $stdout.puts(JSON.generate({ bot_id: bot_id, username: username }))
140
+ exit 0
141
+
142
+ when "--bot-info"
143
+ fail!("--bot-info requires <bot_token>", json: true) if arg.to_s.strip.empty?
144
+ begin
145
+ me = discord_get("/users/@me", bot_token: arg.strip)
146
+ rescue => e
147
+ fail!(e.message, json: true)
148
+ end
149
+ $stdout.puts(JSON.generate({ bot_id: me["id"], username: me["username"] }))
150
+ exit 0
151
+
152
+ when "--invite-url"
153
+ fail!("--invite-url requires <client_id>") if arg.to_s.strip.empty?
154
+ client_id = arg.strip
155
+ url = "#{DISCORD_OAUTH_BASE}?client_id=#{CGI.escape(client_id)}" \
156
+ "&permissions=#{DEFAULT_BOT_PERMS}" \
157
+ "&scope=#{CGI.escape(DEFAULT_BOT_SCOPES)}"
158
+ $stdout.puts(url)
159
+ exit 0
160
+
161
+ when "--watch-guild"
162
+ bot_token = saved_bot_token
163
+ fail!("No saved bot_token in ~/.clacky/channels.yml — run --validate first") if bot_token.to_s.empty?
164
+
165
+ step("Waiting for the bot to be added to a guild (timeout: #{WATCH_GUILD_DEADLINE / 60} min)...")
166
+ deadline = Time.now + WATCH_GUILD_DEADLINE
167
+
168
+ loop do
169
+ fail!("Timed out waiting for the bot to join a guild. Open the invite URL again to retry.") if Time.now > deadline
170
+
171
+ begin
172
+ guilds = discord_get("/users/@me/guilds", bot_token: bot_token)
173
+ rescue => e
174
+ warn!("Poll error (will retry): #{e.message}")
175
+ sleep WATCH_GUILD_INTERVAL
176
+ next
177
+ end
178
+
179
+ if guilds.is_a?(Array) && !guilds.empty?
180
+ g = guilds.first
181
+ ok("Bot added to guild: #{g["name"]} (id=#{g["id"]})")
182
+ $stdout.puts(JSON.generate({ guild_id: g["id"], guild_name: g["name"], total: guilds.length }))
183
+ exit 0
184
+ end
185
+
186
+ sleep WATCH_GUILD_INTERVAL
187
+ end
188
+
189
+ else
190
+ $stderr.puts(<<~USAGE)
191
+ Usage:
192
+ ruby discord_setup.rb --portal-url
193
+ ruby discord_setup.rb --validate <bot_token>
194
+ ruby discord_setup.rb --bot-info <bot_token>
195
+ ruby discord_setup.rb --invite-url <client_id>
196
+ ruby discord_setup.rb --watch-guild
197
+ USAGE
198
+ exit 1
199
+ end
@@ -29,7 +29,7 @@ module Clacky
29
29
  "name" => "OpenClacky",
30
30
  "base_url" => "https://api.openclacky.com",
31
31
  "api" => "bedrock",
32
- "default_model" => "abs-claude-sonnet-4-5",
32
+ "default_model" => "abs-claude-sonnet-4-6",
33
33
  "models" => [
34
34
  "abs-claude-opus-4-7",
35
35
  "abs-claude-opus-4-6",
@@ -80,7 +80,32 @@ module Clacky
80
80
  "base_url" => "https://openrouter.ai/api/v1",
81
81
  "api" => "openai-responses",
82
82
  "default_model" => "anthropic/claude-sonnet-4-6",
83
- "models" => [], # Dynamic - fetched from API
83
+ # Curated default lineup. OpenRouter's full catalogue is enormous
84
+ # (hundreds of models) and the live /models endpoint isn't always
85
+ # reachable from every region — shipping a small list of the
86
+ # mainstream Claude + GPT entries gives users a working dropdown
87
+ # out of the box. Users can still type any other OpenRouter model
88
+ # ID manually; this list only seeds the picker.
89
+ "models" => [
90
+ "anthropic/claude-sonnet-4-6",
91
+ "anthropic/claude-opus-4-7",
92
+ "anthropic/claude-opus-4-6",
93
+ "anthropic/claude-haiku-4-5",
94
+ "openai/gpt-5.5",
95
+ "openai/gpt-5.4",
96
+ "openai/gpt-5.4-mini"
97
+ ],
98
+ # Per-primary lite pairing — Claude family pairs with Haiku, GPT
99
+ # family pairs with the mini variant. Mirrors the openclacky and
100
+ # openai presets above so subagents on OpenRouter get a sensible
101
+ # cheap/fast sidekick automatically.
102
+ "lite_models" => {
103
+ "anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
104
+ "anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
105
+ "anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
106
+ "openai/gpt-5.5" => "openai/gpt-5.4-mini",
107
+ "openai/gpt-5.4" => "openai/gpt-5.4-mini"
108
+ },
84
109
  # Per-model API type overrides. Matched by Regexp against the model name.
85
110
  # Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
86
111
  # /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
@@ -243,8 +268,8 @@ module Clacky
243
268
  "name" => "MiMo (Xiaomi)",
244
269
  "base_url" => "https://api.xiaomimimo.com/v1",
245
270
  "api" => "openai-completions",
246
- "default_model" => "mimo-v2-pro",
247
- "models" => ["mimo-v2-pro", "mimo-v2-omni"],
271
+ "default_model" => "mimo-v2.5-pro",
272
+ "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
248
273
  # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
249
274
  "capabilities" => { "vision" => false }.freeze,
250
275
  "model_capabilities" => {