niahere 0.2.9 → 0.2.11

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.
package/README.md CHANGED
@@ -9,9 +9,9 @@ A personal AI assistant that runs as a background daemon. Handles scheduled jobs
9
9
  ## Quick Start
10
10
 
11
11
  ```bash
12
- bun install -g niahere
13
- nia init # guided setup (database, channels, persona, visual identity)
14
- nia start # starts daemon + registers OS service
12
+ npm i -g niahere # installs globally (prompts to install Bun if missing)
13
+ nia init # guided setup (database, channels, persona, visual identity)
14
+ nia start # starts daemon + registers OS service
15
15
  ```
16
16
 
17
17
  ## Commands
@@ -19,40 +19,56 @@ nia start # starts daemon + registers OS service
19
19
  ```
20
20
  nia init — interactive setup (db, channels, persona, images)
21
21
  nia start / stop — daemon + OS service (launchd/systemd)
22
- nia restart — restart daemon
22
+ nia restart — restart daemon (service-aware)
23
23
  nia status — show daemon, jobs, channels, chat rooms
24
24
  nia chat [-r|--resume] — interactive terminal chat
25
25
  nia run <prompt> — one-shot prompt execution
26
26
  nia history [room] — recent messages
27
27
  nia logs [-f] — daemon logs (follow with -f)
28
- nia send <message> — send a message via configured channel
28
+ nia send [-c channel] <msg> — send a message via channel
29
29
  nia skills — list available skills
30
- nia test [-v] — run tests
31
30
  nia version — show version
32
31
 
33
32
  nia job list — list all jobs
34
33
  nia job show [name] — full details + recent runs
35
- nia job status [name] — quick status check
36
34
  nia job add <n> <s> <p> — add a job (active hours only)
37
35
  nia job add <n> <s> <p> --always — add a cron (runs 24/7)
38
36
  nia job remove <name> — delete a job
39
37
  nia job enable / disable <n> — toggle a job
40
38
  nia job run <name> — run a job once
41
39
  nia job log [name] — show recent run history
40
+
41
+ nia db setup — install PostgreSQL + create database + migrate
42
+ nia db migrate — run database migrations
43
+ nia db status — check database connection
44
+
42
45
  nia channels — show channel status (on/off)
43
46
  nia channels on / off — enable/disable channels
44
47
  ```
45
48
 
46
49
  ## Features
47
50
 
48
- - **Jobs & crons** — jobs run during active hours, crons run 24/7. Stored in PostgreSQL, auto-reload via LISTEN/NOTIFY. Full JSONL traces stored per run with Codex session IDs for inspection.
51
+ - **Jobs & crons** — jobs run during active hours, crons run 24/7. Stored in PostgreSQL, auto-reload via LISTEN/NOTIFY. One-shot jobs auto-disable after execution. Full JSONL traces with Codex session IDs.
49
52
  - **Terminal chat** — REPL with session resume support
50
- - **Telegram** — bot with access control, typing indicator while processing, no placeholder messages
51
- - **Slack** — Socket Mode bot with thinking emoji reactions, thread awareness (auto-listens to follow-ups without @mention), thread context fetching
53
+ - **Telegram** — bot with access control, typing indicator while processing
54
+ - **Slack** — Socket Mode bot with thinking emoji reactions, thread awareness (auto-listens to follow-ups without @mention), thread context fetching, owner vs non-owner access control, prompt injection defense
52
55
  - **Persona system** — customizable identity, soul, owner profile, and on-demand memory
53
56
  - **Visual identity** — AI-generated profile pictures via Gemini, customizable during `nia init`
54
- - **Cross-platform service** — launchd (macOS), systemd (Linux), or plain daemon
55
- - **Skills** — loads user skills from `~/.shared/skills/`, `~/.claude/skills/`, `~/.codex/skills/`, and bundled skills
57
+ - **Cross-platform service** — launchd (macOS), systemd (Linux), service-aware restart
58
+ - **Skills** — loads skills from `~/.shared/skills/`, `~/.claude/skills/`, `~/.codex/skills/`, and bundled skills
59
+ - **Dev mode** — `nia channels off` disables Telegram/Slack for local development without conflicts
60
+
61
+ ## Updating
62
+
63
+ ```bash
64
+ npm i -g niahere # pulls the latest version from npm
65
+ ```
66
+
67
+ To publish a new version after making changes:
68
+
69
+ ```bash
70
+ npm run release # bumps patch version, publishes to npm, pushes git tag
71
+ ```
56
72
 
57
73
  ## Architecture
58
74
 
@@ -65,18 +81,18 @@ All config and data lives in `~/.niahere/`:
65
81
  identity.md — agent personality and voice
66
82
  owner.md — who runs this agent
67
83
  soul.md — how the agent works
68
- memory.md — persistent learnings (read/written on demand, not loaded into context)
84
+ memory.md — persistent learnings (read/written on demand)
69
85
  images/
70
- reference.webp — visual identity reference image
71
- profile.webp — profile picture for Telegram/Slack
86
+ reference.webp — visual identity reference image
87
+ profile.webp — profile picture for Telegram/Slack
72
88
  tmp/
73
89
  nia.pid, daemon.log, cron-state.json, cron-audit.jsonl
74
90
  ```
75
91
 
76
92
  ## Requirements
77
93
 
78
- - [Bun](https://bun.sh) runtime
79
- - PostgreSQL database
94
+ - [Bun](https://bun.sh) runtime (auto-installed if missing)
95
+ - PostgreSQL (`nia db setup` handles installation)
80
96
  - Claude API access (via `@anthropic-ai/claude-agent-sdk`)
81
97
  - Gemini API key (optional, for image generation)
82
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: slack
3
+ description: Atomic Slack primitives for agents. Send, reply, DM, read history, threads, list channels/users, search, react. Use when you need to interact with Slack.
4
+ ---
5
+
6
+ # Slack — Atomic Primitives
7
+
8
+ Single entry point, one subcommand per API call. Agents compose these to build any Slack workflow.
9
+
10
+ ## Setup
11
+
12
+ Credentials: `~/.niahere/config.yaml` → `channels.slack.bot_token`
13
+
14
+ ## Primitives
15
+
16
+ ```bash
17
+ S=~/.shared/skills/slack/slack.py
18
+
19
+ # Send & reply
20
+ python3 $S send --channel C... --text "message"
21
+ python3 $S reply --channel C... --thread-ts 1... --text "reply"
22
+ python3 $S dm --text "message" # DMs dm_user_id
23
+ python3 $S dm --text "message" --user U... # DMs specific user
24
+
25
+ # Read
26
+ python3 $S history --channel C... [--limit 20]
27
+ python3 $S thread --channel C... --thread-ts 1... [--limit 50]
28
+
29
+ # Discovery
30
+ python3 $S channels [--limit 200]
31
+ python3 $S users [--limit 200]
32
+ python3 $S user-info --user U...
33
+ python3 $S search --query "text" [--limit 10]
34
+ python3 $S identity
35
+
36
+ # React
37
+ python3 $S react --channel C... --ts 1... --emoji thumbsup
38
+ ```
39
+
40
+ ## Extracting Thread Info from Slack URL
41
+
42
+ URL format: `https://<workspace>.slack.com/archives/<CHANNEL_ID>/p<THREAD_TS_NO_DOT>`
43
+ - **Channel ID:** the segment after `/archives/`
44
+ - **Thread TS:** the `p` number with a `.` inserted before the last 6 digits
45
+
46
+ ## Beyond these primitives
47
+
48
+ These commands cover common operations but the Slack API has 200+ methods. If you need something not listed here (e.g. managing bookmarks, setting channel topics, pinning messages, scheduling messages, managing user groups):
49
+
50
+ 1. **Check `slack.py --help`** for available subcommands first.
51
+ 2. **Use the Slack API directly** via `curl` with the bot token from `slack_helper.load_slack_config()["token"]`. The pattern is always: `curl -H "Authorization: Bearer $TOKEN" https://slack.com/api/<method>`.
52
+ 3. **Consult the [Slack API docs](https://api.slack.com/methods)** to find the right method and required scopes.
53
+ 4. **Compose primitives** to build workflows — "summarize channel" is just `history` + LLM reasoning. "Find discussions about X" is `search` + `thread`.
54
+
55
+ The bot token and auth pattern are consistent across all Slack APIs. Don't limit yourself to what's explicitly in `slack.py` — treat it as a starting point, not a boundary.
56
+
57
+ ## Design
58
+
59
+ Each subcommand = one Slack API call. No bundled workflows.
60
+ Features are outcomes achieved by an agent composing these primitives in a loop.
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """Atomic Slack primitives. One subcommand = one API call.
3
+
4
+ Usage: python3 slack.py <action> [args]
5
+
6
+ Actions:
7
+ send --channel C... --text "msg" Post to a channel
8
+ reply --channel C... --thread-ts 1... --text "msg" Reply in thread
9
+ dm --text "msg" [--user U...] DM a user (default: dm_user_id)
10
+ history --channel C... [--limit N] Read channel messages
11
+ thread --channel C... --thread-ts 1... [--limit N] Read thread replies
12
+ channels [--limit N] List channels
13
+ users [--limit N] List workspace users
14
+ user-info --user U... Get user profile
15
+ react --channel C... --ts 1... --emoji name Add reaction
16
+ search --query "text" [--limit N] Search messages
17
+ identity Get bot/workspace info
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).parent))
27
+ from slack_helper import load_slack_config, auth_headers, get_identity, open_dm
28
+
29
+ import requests
30
+
31
+
32
+ def _api(method, endpoint, **kwargs):
33
+ url = f"https://slack.com/api/{endpoint}"
34
+ if method == "get":
35
+ resp = requests.get(url, headers=auth_headers(), params=kwargs)
36
+ else:
37
+ resp = requests.post(url, headers={**auth_headers(), "Content-Type": "application/json"}, json=kwargs)
38
+ data = resp.json()
39
+ if not data.get("ok"):
40
+ print(json.dumps({"ok": False, "error": data.get("error")}))
41
+ sys.exit(1)
42
+ return data
43
+
44
+
45
+ def _ts_to_time(ts):
46
+ try:
47
+ return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M:%S")
48
+ except (ValueError, OSError):
49
+ return ts
50
+
51
+
52
+ def cmd_send(args):
53
+ data = _api("post", "chat.postMessage", channel=args.channel, text=args.text)
54
+ print(json.dumps({"ok": True, "channel": args.channel, "ts": data.get("ts")}))
55
+
56
+
57
+ def cmd_reply(args):
58
+ data = _api("post", "chat.postMessage", channel=args.channel, text=args.text, thread_ts=args.thread_ts)
59
+ print(json.dumps({"ok": True, "channel": args.channel, "thread_ts": args.thread_ts, "ts": data.get("ts")}))
60
+
61
+
62
+ def cmd_dm(args):
63
+ config = load_slack_config()
64
+ user_id = args.user or config["dm_user_id"]
65
+ channel_id = open_dm(user_id)
66
+ data = _api("post", "chat.postMessage", channel=channel_id, text=args.text)
67
+ print(json.dumps({"ok": True, "dm_channel": channel_id, "user": user_id, "ts": data.get("ts")}))
68
+
69
+
70
+ def cmd_history(args):
71
+ data = _api("get", "conversations.history", channel=args.channel, limit=args.limit)
72
+ for msg in data.get("messages", []):
73
+ print(f"[{_ts_to_time(msg.get('ts', ''))}] {msg.get('user', 'unknown')}: {msg.get('text', '')}")
74
+
75
+
76
+ def cmd_thread(args):
77
+ data = _api("get", "conversations.replies", channel=args.channel, ts=args.thread_ts, limit=args.limit)
78
+ for msg in data.get("messages", []):
79
+ print(f"[{_ts_to_time(msg.get('ts', ''))}] {msg.get('user', 'unknown')}: {msg.get('text', '')}")
80
+
81
+
82
+ def cmd_channels(args):
83
+ data = _api("get", "conversations.list", types="public_channel,private_channel", limit=args.limit)
84
+ channels = sorted(data.get("channels", []), key=lambda c: c.get("name", ""))
85
+ for ch in channels:
86
+ purpose = ch.get("purpose", {}).get("value", "")[:60]
87
+ print(f"#{ch.get('name', ''):<30} {ch.get('id', '')} ({ch.get('num_members', 0)} members) {purpose}")
88
+
89
+
90
+ def cmd_users(args):
91
+ data = _api("get", "users.list", limit=args.limit)
92
+ for u in data.get("members", []):
93
+ if u.get("deleted") or u.get("is_bot"):
94
+ continue
95
+ name = u.get("real_name") or u.get("name", "unknown")
96
+ print(f"{u.get('id', ''):<12} @{u.get('name', ''):<20} {name}")
97
+
98
+
99
+ def cmd_user_info(args):
100
+ data = _api("get", "users.info", user=args.user)
101
+ u = data.get("user", {})
102
+ profile = u.get("profile", {})
103
+ print(json.dumps({
104
+ "id": u.get("id"),
105
+ "name": u.get("name"),
106
+ "real_name": u.get("real_name"),
107
+ "title": profile.get("title"),
108
+ "email": profile.get("email"),
109
+ "status": profile.get("status_text"),
110
+ "tz": u.get("tz"),
111
+ }, indent=2))
112
+
113
+
114
+ def cmd_react(args):
115
+ _api("post", "reactions.add", channel=args.channel, timestamp=args.ts, name=args.emoji)
116
+ print(json.dumps({"ok": True, "emoji": args.emoji, "channel": args.channel, "ts": args.ts}))
117
+
118
+
119
+ def cmd_search(args):
120
+ data = _api("get", "search.messages", query=args.query, count=args.limit)
121
+ matches = data.get("messages", {}).get("matches", [])
122
+ for m in matches:
123
+ ch = m.get("channel", {}).get("name", "?")
124
+ print(f"[{_ts_to_time(m.get('ts', ''))}] #{ch} {m.get('username', 'unknown')}: {m.get('text', '')}")
125
+
126
+
127
+ def cmd_identity(args):
128
+ info = get_identity()
129
+ print(json.dumps(info, indent=2))
130
+
131
+
132
+ def main():
133
+ parser = argparse.ArgumentParser(description="Atomic Slack primitives")
134
+ sub = parser.add_subparsers(dest="action", required=True)
135
+
136
+ p = sub.add_parser("send")
137
+ p.add_argument("--channel", required=True)
138
+ p.add_argument("--text", required=True)
139
+
140
+ p = sub.add_parser("reply")
141
+ p.add_argument("--channel", required=True)
142
+ p.add_argument("--thread-ts", required=True)
143
+ p.add_argument("--text", required=True)
144
+
145
+ p = sub.add_parser("dm")
146
+ p.add_argument("--text", required=True)
147
+ p.add_argument("--user", default=None)
148
+
149
+ p = sub.add_parser("history")
150
+ p.add_argument("--channel", required=True)
151
+ p.add_argument("--limit", type=int, default=20)
152
+
153
+ p = sub.add_parser("thread")
154
+ p.add_argument("--channel", required=True)
155
+ p.add_argument("--thread-ts", required=True)
156
+ p.add_argument("--limit", type=int, default=50)
157
+
158
+ p = sub.add_parser("channels")
159
+ p.add_argument("--limit", type=int, default=200)
160
+
161
+ p = sub.add_parser("users")
162
+ p.add_argument("--limit", type=int, default=200)
163
+
164
+ p = sub.add_parser("user-info")
165
+ p.add_argument("--user", required=True)
166
+
167
+ p = sub.add_parser("react")
168
+ p.add_argument("--channel", required=True)
169
+ p.add_argument("--ts", required=True)
170
+ p.add_argument("--emoji", required=True)
171
+
172
+ p = sub.add_parser("search")
173
+ p.add_argument("--query", required=True)
174
+ p.add_argument("--limit", type=int, default=10)
175
+
176
+ p = sub.add_parser("identity")
177
+
178
+ args = parser.parse_args()
179
+ {
180
+ "send": cmd_send,
181
+ "reply": cmd_reply,
182
+ "dm": cmd_dm,
183
+ "history": cmd_history,
184
+ "thread": cmd_thread,
185
+ "channels": cmd_channels,
186
+ "users": cmd_users,
187
+ "user-info": cmd_user_info,
188
+ "react": cmd_react,
189
+ "search": cmd_search,
190
+ "identity": cmd_identity,
191
+ }[args.action](args)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """Shared Slack config helper. Reads creds from ~/.niahere/config.yaml."""
3
+
4
+ import yaml
5
+ import requests
6
+ from pathlib import Path
7
+
8
+ CONFIG_PATH = Path.home() / ".niahere" / "config.yaml"
9
+
10
+ _identity_cache = None
11
+
12
+ def load_slack_config():
13
+ """Load Slack config from ~/.niahere/config.yaml."""
14
+ with open(CONFIG_PATH) as f:
15
+ config = yaml.safe_load(f)
16
+ slack = config["channels"]["slack"]
17
+ return {
18
+ "token": slack["bot_token"],
19
+ "app_token": slack.get("app_token"),
20
+ "dm_user_id": slack.get("dm_user_id"),
21
+ }
22
+
23
+ def get_identity():
24
+ """Call auth.test to get bot identity and workspace info. Cached per process."""
25
+ global _identity_cache
26
+ if _identity_cache:
27
+ return _identity_cache
28
+ resp = requests.get("https://slack.com/api/auth.test", headers=auth_headers())
29
+ data = resp.json()
30
+ if not data.get("ok"):
31
+ raise RuntimeError(f"Slack auth failed: {data.get('error')}")
32
+ _identity_cache = {
33
+ "bot_user_id": data["user_id"],
34
+ "bot_id": data["bot_id"],
35
+ "bot_name": data["user"],
36
+ "workspace": data["team"],
37
+ "workspace_id": data["team_id"],
38
+ "workspace_url": data["url"],
39
+ }
40
+ return _identity_cache
41
+
42
+ def auth_headers(token=None):
43
+ """Return Authorization headers for Slack API calls."""
44
+ if not token:
45
+ token = load_slack_config()["token"]
46
+ return {"Authorization": f"Bearer {token}"}
47
+
48
+ def open_dm(user_id=None):
49
+ """Open a DM channel with a user. Defaults to dm_user_id from config."""
50
+ config = load_slack_config()
51
+ user_id = user_id or config["dm_user_id"]
52
+ resp = requests.post(
53
+ "https://slack.com/api/conversations.open",
54
+ headers={**auth_headers(), "Content-Type": "application/json"},
55
+ json={"users": user_id},
56
+ )
57
+ data = resp.json()
58
+ if not data.get("ok"):
59
+ raise RuntimeError(f"Failed to open DM: {data.get('error')}")
60
+ return data["channel"]["id"]
@@ -13,12 +13,15 @@ class SlackChannel implements Channel {
13
13
  private app: App | null = null;
14
14
  private defaultChannelId: string | null = null;
15
15
  private dmUserId: string | null = null;
16
+ /** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
17
+ private outboundTs = new Set<string>();
16
18
 
17
19
  async sendMessage(text: string): Promise<void> {
18
20
  if (!this.app) throw new Error("Slack not started");
19
21
  const target = this.defaultChannelId || this.dmUserId;
20
22
  if (!target) throw new Error("No Slack recipient — DM the bot first, or set slack_channel_id in config");
21
- await this.app.client.chat.postMessage({ channel: target, text });
23
+ const result = await this.app.client.chat.postMessage({ channel: target, text });
24
+ if (result.ts) this.outboundTs.add(result.ts);
22
25
  }
23
26
 
24
27
  async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
@@ -216,7 +219,8 @@ class SlackChannel implements Channel {
216
219
  const isMention = botUserId && msg.text?.includes(`<@${botUserId}>`);
217
220
  const hasFiles = msg.files && msg.files.length > 0;
218
221
 
219
- // In threads where Nia already has a session (in-memory or DB), listen without @mention
222
+ // In threads where Nia already has a session (in-memory or DB), listen without @mention.
223
+ // Also catches replies to messages Nia posted proactively (outbound tracking + bot-authored fallback).
220
224
  let isActiveThread = false;
221
225
  if (!isDm && msg.thread_ts) {
222
226
  const channelName = await resolveChannelName(app, msg.channel);
@@ -230,6 +234,30 @@ class SlackChannel implements Channel {
230
234
  const sessionId = await Session.getLatest(latestRoom);
231
235
  isActiveThread = sessionId !== null;
232
236
  }
237
+
238
+ // Fast path: we tracked this ts when we sent it
239
+ if (!isActiveThread && self.outboundTs.has(msg.thread_ts)) {
240
+ isActiveThread = true;
241
+ }
242
+
243
+ // Fallback: check if the thread parent was posted by the bot
244
+ if (!isActiveThread && botUserId) {
245
+ try {
246
+ const parent = await client.conversations.replies({
247
+ channel: msg.channel,
248
+ ts: msg.thread_ts,
249
+ limit: 1,
250
+ inclusive: true,
251
+ });
252
+ const parentMsg = parent.messages?.[0];
253
+ if (parentMsg && (parentMsg.user === botUserId || parentMsg.bot_id)) {
254
+ isActiveThread = true;
255
+ log.debug({ channel: msg.channel, thread_ts: msg.thread_ts }, "thread parent is bot-authored, activating");
256
+ }
257
+ } catch (err) {
258
+ log.warn({ err, channel: msg.channel, thread_ts: msg.thread_ts }, "failed to check thread parent");
259
+ }
260
+ }
233
261
  }
234
262
 
235
263
  if (!isDm && !isMention && !isActiveThread) {
@@ -2,6 +2,7 @@ import { getConfig, updateRawConfig } from "../utils/config";
2
2
  import { getPaths } from "../utils/paths";
3
3
  import { errMsg } from "../utils/errors";
4
4
  import { fail } from "../utils/cli";
5
+ import { log } from "../utils/log";
5
6
 
6
7
  export async function sendCommand(): Promise<void> {
7
8
  const args = process.argv.slice(3);
@@ -51,7 +52,34 @@ export function telegramCommand(): void {
51
52
  console.log("Run `nia restart` to activate.");
52
53
  }
53
54
 
54
- export function slackCommand(): void {
55
+ /** Call Slack auth.test to enrich config with workspace/bot info. */
56
+ export async function enrichSlackConfig(botToken: string): Promise<Record<string, unknown>> {
57
+ try {
58
+ const resp = await fetch("https://slack.com/api/auth.test", {
59
+ headers: { Authorization: `Bearer ${botToken}` },
60
+ });
61
+ const data = (await resp.json()) as Record<string, unknown>;
62
+ if (!data.ok) {
63
+ log.warn({ error: data.error }, "Slack auth.test failed, skipping enrichment");
64
+ return {};
65
+ }
66
+ const enriched = {
67
+ bot_user_id: data.user_id,
68
+ bot_name: data.user,
69
+ workspace: data.team,
70
+ workspace_id: data.team_id,
71
+ workspace_url: data.url,
72
+ };
73
+ console.log(` Slack workspace: ${data.team} (${data.url})`);
74
+ console.log(` Bot: @${data.user} (${data.user_id})`);
75
+ return enriched;
76
+ } catch (err) {
77
+ log.warn({ err }, "Failed to reach Slack API, skipping enrichment");
78
+ return {};
79
+ }
80
+ }
81
+
82
+ export async function slackCommand(): Promise<void> {
55
83
  const botToken = process.argv[3];
56
84
  const appToken = process.argv[4];
57
85
 
@@ -59,6 +87,32 @@ export function slackCommand(): void {
59
87
  const config = getConfig();
60
88
  if (config.channels.slack.bot_token) {
61
89
  console.log(`Slack: configured (...${config.channels.slack.bot_token.slice(-6)})`);
90
+ if (config.channels.slack.workspace) {
91
+ console.log(` Workspace: ${config.channels.slack.workspace} (${config.channels.slack.workspace_url})`);
92
+ console.log(` Bot: @${config.channels.slack.bot_name} (${config.channels.slack.bot_user_id})`);
93
+ }
94
+ // Verify auth is working
95
+ try {
96
+ const resp = await fetch("https://slack.com/api/auth.test", {
97
+ headers: { Authorization: `Bearer ${config.channels.slack.bot_token}` },
98
+ });
99
+ const data = (await resp.json()) as Record<string, unknown>;
100
+ if (data.ok) {
101
+ console.log(` Auth: \u2713 valid`);
102
+ // Backfill workspace info if missing
103
+ if (!config.channels.slack.workspace) {
104
+ const enriched = await enrichSlackConfig(config.channels.slack.bot_token);
105
+ if (Object.keys(enriched).length > 0) {
106
+ updateRawConfig({ channels: { slack: enriched } });
107
+ console.log(" (workspace info backfilled)");
108
+ }
109
+ }
110
+ } else {
111
+ console.log(` Auth: \u2717 ${data.error}`);
112
+ }
113
+ } catch (err) {
114
+ console.log(` Auth: \u2717 could not reach Slack API`);
115
+ }
62
116
  } else {
63
117
  console.log("Slack: not configured");
64
118
  }
@@ -72,6 +126,11 @@ export function slackCommand(): void {
72
126
  const sl: Record<string, unknown> = { bot_token: botToken, app_token: appToken };
73
127
  const channelId = process.argv[5];
74
128
  if (channelId) sl.channel_id = channelId;
129
+
130
+ // Enrich with workspace/bot info from auth.test
131
+ const enriched = await enrichSlackConfig(botToken);
132
+ Object.assign(sl, enriched);
133
+
75
134
  updateRawConfig({ channels: { slack: sl } });
76
135
 
77
136
  console.log(`Slack tokens saved to ${getPaths().config}`);
package/src/cli/index.ts CHANGED
@@ -219,7 +219,7 @@ switch (command) {
219
219
  }
220
220
 
221
221
  case "slack": {
222
- slackCommand();
222
+ await slackCommand();
223
223
  break;
224
224
  }
225
225
 
package/src/cli/status.ts CHANGED
@@ -7,6 +7,7 @@ import { Message, ActiveEngine, Job } from "../db/models";
7
7
  import type { ScheduleType, JobStateStatus, RoomStats } from "../types";
8
8
  import { withDb } from "../db/connection";
9
9
  import { errMsg } from "../utils/errors";
10
+ import { checkForUpdate } from "../utils/update";
10
11
 
11
12
  type StatusOptions = {
12
13
  json: boolean;
@@ -306,4 +307,13 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
306
307
  } else {
307
308
  console.log("Tip: use --rooms N, --all, or --json for alternate views.");
308
309
  }
310
+
311
+ // Check for updates (non-blocking, cached 24h)
312
+ try {
313
+ const { version } = await import("../../package.json");
314
+ const update = await checkForUpdate(version);
315
+ if (update) {
316
+ console.log(`\n⚠ Update available: ${update.current} → ${update.latest} (run \`npm i -g niahere\` to update)`);
317
+ }
318
+ } catch {}
309
319
  }
@@ -8,6 +8,7 @@ import { runMigrations } from "../db/migrate";
8
8
  import { closeDb } from "../db/connection";
9
9
  import { startDaemon, isRunning } from "../core/daemon";
10
10
  import { errMsg } from "../utils/errors";
11
+ import { enrichSlackConfig } from "../cli/channels";
11
12
  import yaml from "js-yaml";
12
13
 
13
14
  const DEFAULTS_DIR = resolve(import.meta.dir, "../../defaults/self");
@@ -396,6 +397,9 @@ export async function runInit(): Promise<void> {
396
397
  if (slackBotToken && slackAppToken) {
397
398
  const sl: Record<string, unknown> = { bot_token: slackBotToken, app_token: slackAppToken };
398
399
  if (slackChannelId) sl.channel_id = slackChannelId;
400
+ // Enrich with workspace/bot info from auth.test
401
+ const enriched = await enrichSlackConfig(slackBotToken);
402
+ Object.assign(sl, enriched);
399
403
  channels.slack = sl;
400
404
  }
401
405
  if (slackBotToken && !telegramToken) {
@@ -162,6 +162,17 @@ export async function runDaemon(): Promise<void> {
162
162
  writePid(process.pid);
163
163
  log.info({ pid: process.pid }, "daemon started");
164
164
 
165
+ // Check for updates (non-blocking, logged only)
166
+ try {
167
+ const { checkForUpdate } = await import("../utils/update");
168
+ const { version } = await import("../../package.json");
169
+ const update = await checkForUpdate(version);
170
+ if (update) {
171
+ log.warn({ current: update.current, latest: update.latest }, "update available — run `npm i -g niahere` to update");
172
+ }
173
+ } catch {}
174
+
175
+
165
176
  // Startup recovery
166
177
  try {
167
178
  await runMigrations();
@@ -9,6 +9,11 @@ export interface SlackConfig {
9
9
  app_token: string | null;
10
10
  channel_id: string | null;
11
11
  dm_user_id: string | null;
12
+ bot_user_id: string | null;
13
+ bot_name: string | null;
14
+ workspace: string | null;
15
+ workspace_id: string | null;
16
+ workspace_url: string | null;
12
17
  }
13
18
 
14
19
  export interface ChannelsConfig {
@@ -19,7 +19,7 @@ const DEFAULTS: Config = {
19
19
  enabled: true,
20
20
  default: "telegram",
21
21
  telegram: { bot_token: null, chat_id: null, open: false },
22
- slack: { bot_token: null, app_token: null, channel_id: null, dm_user_id: null },
22
+ slack: { bot_token: null, app_token: null, channel_id: null, dm_user_id: null, bot_user_id: null, bot_name: null, workspace: null, workspace_id: null, workspace_url: null },
23
23
  },
24
24
  };
25
25
 
@@ -127,6 +127,17 @@ export function loadConfig(): Config {
127
127
  const slDmUserId =
128
128
  typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null;
129
129
 
130
+ const slBotUserId =
131
+ typeof chSl.bot_user_id === "string" ? chSl.bot_user_id : null;
132
+ const slBotName =
133
+ typeof chSl.bot_name === "string" ? chSl.bot_name : null;
134
+ const slWorkspace =
135
+ typeof chSl.workspace === "string" ? chSl.workspace : null;
136
+ const slWorkspaceId =
137
+ typeof chSl.workspace_id === "string" ? chSl.workspace_id : null;
138
+ const slWorkspaceUrl =
139
+ typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
140
+
130
141
  return {
131
142
  model,
132
143
  timezone,
@@ -138,7 +149,7 @@ export function loadConfig(): Config {
138
149
  enabled: channelsEnabled,
139
150
  default: defaultChannel,
140
151
  telegram: { bot_token: tgBotToken, chat_id: tgChatId, open: tgOpen },
141
- slack: { bot_token: slBotToken, app_token: slAppToken, channel_id: slChannelId, dm_user_id: slDmUserId },
152
+ slack: { bot_token: slBotToken, app_token: slAppToken, channel_id: slChannelId, dm_user_id: slDmUserId, bot_user_id: slBotUserId, bot_name: slBotName, workspace: slWorkspace, workspace_id: slWorkspaceId, workspace_url: slWorkspaceUrl },
142
153
  },
143
154
  };
144
155
  }
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { resolve, dirname } from "path";
3
+ import { getNiaHome } from "./paths";
4
+
5
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
6
+ const PACKAGE_NAME = "niahere";
7
+
8
+ type UpdateCache = {
9
+ latest: string;
10
+ checkedAt: number;
11
+ };
12
+
13
+ function cachePath(): string {
14
+ return resolve(getNiaHome(), "tmp/update-check.json");
15
+ }
16
+
17
+ function readCache(): UpdateCache | null {
18
+ const path = cachePath();
19
+ if (!existsSync(path)) return null;
20
+ try {
21
+ const data = JSON.parse(readFileSync(path, "utf8"));
22
+ if (data.latest && data.checkedAt && Date.now() - data.checkedAt < CACHE_TTL_MS) {
23
+ return data;
24
+ }
25
+ } catch {}
26
+ return null;
27
+ }
28
+
29
+ function writeCache(latest: string): void {
30
+ const path = cachePath();
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ writeFileSync(path, JSON.stringify({ latest, checkedAt: Date.now() }));
33
+ }
34
+
35
+ async function fetchLatestVersion(): Promise<string | null> {
36
+ try {
37
+ const resp = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
38
+ signal: AbortSignal.timeout(3000),
39
+ });
40
+ if (!resp.ok) return null;
41
+ const data = await resp.json() as { version?: string };
42
+ return data.version ?? null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function isNewer(latest: string, current: string): boolean {
49
+ const [la, lb, lc] = latest.split(".").map(Number);
50
+ const [ca, cb, cc] = current.split(".").map(Number);
51
+ if (la !== ca) return la > ca;
52
+ if (lb !== cb) return lb > cb;
53
+ return lc > cc;
54
+ }
55
+
56
+ /** Check if a newer version is available. Returns update info or null. Non-blocking, cached 24h. */
57
+ export async function checkForUpdate(currentVersion: string): Promise<{ current: string; latest: string } | null> {
58
+ const cached = readCache();
59
+ if (cached) {
60
+ return isNewer(cached.latest, currentVersion) ? { current: currentVersion, latest: cached.latest } : null;
61
+ }
62
+
63
+ const latest = await fetchLatestVersion();
64
+ if (!latest) return null;
65
+
66
+ writeCache(latest);
67
+ return isNewer(latest, currentVersion) ? { current: currentVersion, latest } : null;
68
+ }