niahere 0.2.9 → 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 +1 -1
- 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/commands/init.ts +4 -0
- 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
|
@@ -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/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/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
|
}
|