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 +33 -17
- package/package.json +1 -1
- package/skills/slack/SKILL.md +60 -0
- package/skills/slack/slack.py +195 -0
- package/skills/slack/slack_helper.py +60 -0
- package/src/channels/slack.ts +30 -2
- package/src/cli/channels.ts +60 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/status.ts +10 -0
- package/src/commands/init.ts +4 -0
- package/src/core/daemon.ts +11 -0
- package/src/types/config.ts +5 -0
- package/src/utils/config.ts +13 -2
- package/src/utils/update.ts +68 -0
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
|
-
|
|
13
|
-
nia init
|
|
14
|
-
nia start
|
|
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 <
|
|
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
|
|
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
|
|
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),
|
|
55
|
-
- **Skills** — loads
|
|
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
|
|
84
|
+
memory.md — persistent learnings (read/written on demand)
|
|
69
85
|
images/
|
|
70
|
-
reference.webp
|
|
71
|
-
profile.webp
|
|
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
|
|
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
|
@@ -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"]
|
package/src/channels/slack.ts
CHANGED
|
@@ -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) {
|
package/src/cli/channels.ts
CHANGED
|
@@ -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
|
-
|
|
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
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
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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) {
|
package/src/core/daemon.ts
CHANGED
|
@@ -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();
|
package/src/types/config.ts
CHANGED
|
@@ -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 {
|
package/src/utils/config.ts
CHANGED
|
@@ -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
|
+
}
|