niahere 0.2.8 → 0.2.10
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 +3 -2
- package/skills/slack/SKILL.md +49 -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 +38 -1
- package/src/cli/index.ts +7 -0
- package/src/commands/db.ts +130 -0
- package/src/commands/init.ts +11 -1
- package/src/types/config.ts +5 -0
- package/src/utils/config.ts +13 -2
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niahere",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"test": "tsc --noEmit && LOG_LEVEL=silent bun test --reporter=dots",
|
|
12
12
|
"test:bun": "bun test",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
|
-
"seed": "bun run src/db/seed.ts"
|
|
14
|
+
"seed": "bun run src/db/seed.ts",
|
|
15
|
+
"release": "npm version patch && npm publish && git push"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|
|
17
18
|
"ai",
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
## Design
|
|
47
|
+
|
|
48
|
+
Each subcommand = one Slack API call. No bundled workflows.
|
|
49
|
+
Features like "summarize channel" or "find discussions" are agent-level compositions of these primitives.
|
|
@@ -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,10 @@ 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
|
+
}
|
|
62
94
|
} else {
|
|
63
95
|
console.log("Slack: not configured");
|
|
64
96
|
}
|
|
@@ -72,6 +104,11 @@ export function slackCommand(): void {
|
|
|
72
104
|
const sl: Record<string, unknown> = { bot_token: botToken, app_token: appToken };
|
|
73
105
|
const channelId = process.argv[5];
|
|
74
106
|
if (channelId) sl.channel_id = channelId;
|
|
107
|
+
|
|
108
|
+
// Enrich with workspace/bot info from auth.test
|
|
109
|
+
const enriched = await enrichSlackConfig(botToken);
|
|
110
|
+
Object.assign(sl, enriched);
|
|
111
|
+
|
|
75
112
|
updateRawConfig({ channels: { slack: sl } });
|
|
76
113
|
|
|
77
114
|
console.log(`Slack tokens saved to ${getPaths().config}`);
|
package/src/cli/index.ts
CHANGED
|
@@ -238,6 +238,12 @@ switch (command) {
|
|
|
238
238
|
break;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
case "db": {
|
|
242
|
+
const { dbCommand } = await import("../commands/db");
|
|
243
|
+
await dbCommand();
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
241
247
|
case "test": {
|
|
242
248
|
const verbose = process.argv.includes("-v") || process.argv.includes("--verbose");
|
|
243
249
|
const extraArgs = process.argv.slice(3).filter((a) => a !== "-v" && a !== "--verbose");
|
|
@@ -285,6 +291,7 @@ switch (command) {
|
|
|
285
291
|
console.log(" history [room] — recent messages");
|
|
286
292
|
console.log(" logs [-f] — daemon logs");
|
|
287
293
|
console.log(" job <sub> — manage jobs");
|
|
294
|
+
console.log(" db <sub> — database setup/status/migrate");
|
|
288
295
|
console.log(" skills — list available skills");
|
|
289
296
|
console.log(" send [-c ch] <msg> — send a message via channel");
|
|
290
297
|
console.log(" telegram <token> — configure telegram");
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { getConfig } from "../utils/config";
|
|
2
|
+
import { runMigrations } from "../db/migrate";
|
|
3
|
+
import { closeDb, getSql } from "../db/connection";
|
|
4
|
+
import { errMsg } from "../utils/errors";
|
|
5
|
+
|
|
6
|
+
export async function dbSetup(): Promise<void> {
|
|
7
|
+
console.log("Setting up PostgreSQL...\n");
|
|
8
|
+
|
|
9
|
+
const pgCheck = Bun.spawnSync(["which", "psql"]);
|
|
10
|
+
const hasPostgres = pgCheck.exitCode === 0;
|
|
11
|
+
|
|
12
|
+
if (!hasPostgres) {
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
console.log(" PostgreSQL not found. Installing via Homebrew...");
|
|
15
|
+
const brew = Bun.spawn(["brew", "install", "postgresql@17"], { stdout: "inherit", stderr: "inherit" });
|
|
16
|
+
if (await brew.exited !== 0) {
|
|
17
|
+
console.log(" \u2717 brew install failed. Install manually: brew install postgresql@17");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
console.log(" \u2713 PostgreSQL installed");
|
|
21
|
+
|
|
22
|
+
console.log(" Starting PostgreSQL...");
|
|
23
|
+
const start = Bun.spawn(["brew", "services", "start", "postgresql@17"], { stdout: "pipe", stderr: "pipe" });
|
|
24
|
+
if (await start.exited !== 0) {
|
|
25
|
+
console.log(" \u2717 could not start. Try: brew services start postgresql@17");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(" \u2713 PostgreSQL started");
|
|
29
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
30
|
+
} else {
|
|
31
|
+
console.log(" PostgreSQL not found.");
|
|
32
|
+
console.log(" Install it:");
|
|
33
|
+
console.log(" Ubuntu/Debian: sudo apt install postgresql");
|
|
34
|
+
console.log(" Fedora: sudo dnf install postgresql-server");
|
|
35
|
+
console.log(" Arch: sudo pacman -S postgresql");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
const ready = Bun.spawnSync(["pg_isready"]);
|
|
40
|
+
if (ready.exitCode !== 0) {
|
|
41
|
+
console.log(" PostgreSQL installed but not running.");
|
|
42
|
+
if (process.platform === "darwin") {
|
|
43
|
+
console.log(" Starting...");
|
|
44
|
+
const start = Bun.spawn(["brew", "services", "start", "postgresql@17"], { stdout: "pipe", stderr: "pipe" });
|
|
45
|
+
await start.exited;
|
|
46
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
47
|
+
if (Bun.spawnSync(["pg_isready"]).exitCode === 0) {
|
|
48
|
+
console.log(" \u2713 PostgreSQL started");
|
|
49
|
+
} else {
|
|
50
|
+
console.log(" \u2717 could not start. Check: brew services list");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
console.log(" Start it: sudo systemctl start postgresql");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.log(" \u2713 PostgreSQL running");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create database
|
|
63
|
+
const config = getConfig();
|
|
64
|
+
const dbName = config.database_url.split("/").pop() || "niahere";
|
|
65
|
+
const createDb = Bun.spawnSync(["createdb", dbName]);
|
|
66
|
+
if (createDb.exitCode === 0) {
|
|
67
|
+
console.log(` \u2713 database "${dbName}" created`);
|
|
68
|
+
} else {
|
|
69
|
+
const stderr = new TextDecoder().decode(createDb.stderr);
|
|
70
|
+
if (stderr.includes("already exists")) {
|
|
71
|
+
console.log(` \u2713 database "${dbName}" already exists`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(` \u2717 createdb failed: ${stderr.trim()}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Run migrations
|
|
79
|
+
try {
|
|
80
|
+
await runMigrations();
|
|
81
|
+
console.log(" \u2713 migrations done");
|
|
82
|
+
await closeDb();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.log(` \u2717 migrations failed: ${errMsg(err)}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log("\nDatabase ready.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function dbCommand(): Promise<void> {
|
|
91
|
+
const sub = process.argv[3];
|
|
92
|
+
|
|
93
|
+
switch (sub) {
|
|
94
|
+
case "setup":
|
|
95
|
+
await dbSetup();
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
case "migrate": {
|
|
99
|
+
try {
|
|
100
|
+
await runMigrations();
|
|
101
|
+
console.log("Migrations done.");
|
|
102
|
+
await closeDb();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.log(`Failed: ${errMsg(err)}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case "status": {
|
|
111
|
+
try {
|
|
112
|
+
const sql = getSql();
|
|
113
|
+
await sql`SELECT 1`;
|
|
114
|
+
console.log("Database: connected");
|
|
115
|
+
await closeDb();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.log(`Database: unavailable (${errMsg(err)})`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
console.log("Usage: nia db <command>\n");
|
|
125
|
+
console.log(" setup — install PostgreSQL + create database + migrate");
|
|
126
|
+
console.log(" migrate — run database migrations");
|
|
127
|
+
console.log(" status — check database connection");
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
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");
|
|
@@ -114,7 +115,13 @@ export async function runInit(): Promise<void> {
|
|
|
114
115
|
} catch (err) {
|
|
115
116
|
const msg = errMsg(err);
|
|
116
117
|
console.log(` \u2717 could not connect: ${msg}`);
|
|
117
|
-
|
|
118
|
+
const setupDb = await ask(rl, " Set up PostgreSQL now? (y/n)", "y");
|
|
119
|
+
if (setupDb.toLowerCase() === "y") {
|
|
120
|
+
const { dbSetup } = await import("./db");
|
|
121
|
+
await dbSetup();
|
|
122
|
+
} else {
|
|
123
|
+
console.log(` (run 'nia db setup' later)\n`);
|
|
124
|
+
}
|
|
118
125
|
}
|
|
119
126
|
delete process.env.DATABASE_URL;
|
|
120
127
|
|
|
@@ -390,6 +397,9 @@ export async function runInit(): Promise<void> {
|
|
|
390
397
|
if (slackBotToken && slackAppToken) {
|
|
391
398
|
const sl: Record<string, unknown> = { bot_token: slackBotToken, app_token: slackAppToken };
|
|
392
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);
|
|
393
403
|
channels.slack = sl;
|
|
394
404
|
}
|
|
395
405
|
if (slackBotToken && !telegramToken) {
|
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
|
}
|