eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// eleven is the Eleven Messenger command line: a full E2EE member in your
2// terminal. Sign in once with your passkey (`eleven signin`) to pull every
3// space saved in your Eleven account, or join a single space with an invite
4// link — either way the private keys live in local files and the servers only
5// ever see ciphertext, exactly like every other device.
6//
7// A separate small binary (like push/ and home/), not an lchat subcommand:
8// members install the client, operators install the server.
9package main
10
11import (
12 "bufio"
13 "context"
14 "flag"
15 "fmt"
16 "io"
17 "os"
18 "path/filepath"
19 "strings"
20 "time"
21
22 "lchat/internal/client"
23)
24
25func main() {
26 fs := flag.NewFlagSet("eleven", flag.ExitOnError)
27 sessionPath := fs.String("session", envOr("ELEVEN_SESSION", ""),
28 "use ONE identity file instead of the ~/.eleven store (env: ELEVEN_SESSION)")
29 dir := fs.String("dir", envOr("ELEVEN_DIR", defaultDir()),
30 "the eleven store directory (env: ELEVEN_DIR)")
31 fs.Usage = usage(fs)
32 _ = fs.Parse(os.Args[1:])
33 args := fs.Args()
34 if len(args) == 0 {
35 fs.Usage()
36 os.Exit(2)
37 }
38
39 switch args[0] {
40 case "signin":
41 if *sessionPath != "" {
42 die("signin fills the ~/.eleven store — drop the -session flag")
43 }
44 st, err := openStore(*dir)
45 if err != nil {
46 die("%v", err)
47 }
48 runSignin(st, args[1:])
49 case "join":
50 runJoin(*dir, *sessionPath, args[1:])
51 case "spaces":
52 runSpaces(mustSpaces(*dir, *sessionPath))
53 case "chats":
54 runChats(mustSpaces(*dir, *sessionPath))
55 case "chat":
56 runChat(mustSpaces(*dir, *sessionPath), strings.Join(args[1:], " "))
57 case "send":
58 runSend(mustSpaces(*dir, *sessionPath), args[1:])
59 case "vault":
60 runVault(mustSpaces(*dir, *sessionPath), args[1:])
61 case "help", "-h", "--help":
62 fs.Usage()
63 default:
64 fmt.Fprintf(os.Stderr, "eleven: unknown command %q\n\n", args[0])
65 fs.Usage()
66 os.Exit(2)
67 }
68}
69
70func usage(fs *flag.FlagSet) func() {
71 return func() {
72 fmt.Fprint(os.Stderr, `usage: eleven <command>
73
74commands:
75 signin [--paste] [--fresh] sign in with your passkey (via your browser) and
76 pull every space saved in your Eleven account;
77 re-syncs silently once signed in
78 join [--name you] [link] join one space with an invite link (add-device
79 links sign this terminal in as you)
80 spaces list the spaces this terminal is signed into
81 chats list your chats
82 chat [name] open a chat and talk; newest chat when no name.
83 Type to send, Ctrl-C or Ctrl-D to leave
84 send <name> [text] send one message and exit (reads stdin without text)
85 vault <command> the Vault app from the terminal: list/get/set
86 secrets, enroll this machine with a Touch ID key
87 ("eleven vault" shows the full set)
88
89Chat names match by substring; prefix with a space to disambiguate
90("family/kitchen"). Identities live in ~/.eleven — those files hold your
91private keys; keep them safe.
92
93`)
94 fs.PrintDefaults()
95 }
96}
97
98func defaultDir() string {
99 home, err := os.UserHomeDir()
100 if err != nil {
101 return ".eleven"
102 }
103 return filepath.Join(home, ".eleven")
104}
105
106func envOr(key, def string) string {
107 if v := strings.TrimSpace(os.Getenv(key)); v != "" {
108 return v
109 }
110 return def
111}
112
113func die(format string, args ...any) {
114 fmt.Fprintf(os.Stderr, "eleven: "+format+"\n", args...)
115 os.Exit(1)
116}
117
118// space is one signed-in space: its session plus a lazily-shared client.
119type space struct {
120 sess *client.Session
121 c *client.Client
122}
123
124// mustSpaces loads every identity this terminal holds: the explicit -session
125// file when given (single-space mode), else the ~/.eleven store. Fresh
126// members' name publish is retried here (it needs the instance key, granted
127// asynchronously by another member's device).
128func mustSpaces(dir, sessionPath string) []*space {
129 if sessionPath != "" {
130 sess, err := client.LoadSession(sessionPath)
131 if err != nil {
132 die("%v", err)
133 }
134 if sess == nil {
135 die("no identity at %s — run `eleven join` or `eleven signin` first", sessionPath)
136 }
137 if sess.Base == "" {
138 die("%s has no server URL — re-join with `eleven join`", sessionPath)
139 }
140 return []*space{newSpace(sess, sessionPath)}
141 }
142 st, err := openStore(dir)
143 if err != nil {
144 die("%v", err)
145 }
146 sessions, err := st.spaces()
147 if err != nil {
148 die("%v", err)
149 }
150 if len(sessions) == 0 {
151 die("no spaces yet — `eleven signin` (passkey) or `eleven join` (invite link) first")
152 }
153 var out []*space
154 for _, sess := range sessions {
155 path, err := st.spacePath(sess)
156 if err != nil {
157 die("%v", err)
158 }
159 out = append(out, newSpace(sess, path))
160 }
161 return out
162}
163
164func newSpace(sess *client.Session, path string) *space {
165 sp := &space{sess: sess, c: &client.Client{Base: sess.Base, Session: sess}}
166 if !sess.LabelPublished {
167 if err := sp.c.PublishLabel(); err == nil {
168 sess.LabelPublished = true
169 _ = sess.Save(path)
170 }
171 }
172 return sp
173}
174
175func runJoin(dir, sessionPath string, args []string) {
176 fs := flag.NewFlagSet("eleven join", flag.ExitOnError)
177 name := fs.String("name", "", "your display name (prompts if the link doesn't carry one)")
178 _ = fs.Parse(args)
179
180 in := bufio.NewReader(os.Stdin)
181 link := strings.TrimSpace(strings.Join(fs.Args(), " "))
182 if link == "" {
183 fmt.Fprint(os.Stderr, "Paste your invite link: ")
184 line, err := in.ReadString('\n')
185 if err != nil && strings.TrimSpace(line) == "" {
186 die("no invite link given")
187 }
188 link = strings.TrimSpace(line)
189 }
190 inv, err := client.ParseInviteLink(link)
191 if err != nil {
192 die("%v", err)
193 }
194
195 // One identity per space per store: joining a space this terminal already
196 // holds would orphan the existing keys.
197 var st *store
198 if sessionPath == "" {
199 if st, err = openStore(dir); err != nil {
200 die("%v", err)
201 }
202 existing, err := st.spaces()
203 if err != nil {
204 die("%v", err)
205 }
206 for _, e := range existing {
207 if e.Base == inv.Base {
208 die("already signed into %s (as %q) — `eleven chat` to use it", inv.Base, e.Name)
209 }
210 }
211 } else if existing, err := client.LoadSession(sessionPath); err != nil {
212 die("%v", err)
213 } else if existing != nil {
214 die("already joined as %q (%s) — delete %s to start over", existing.Name, existing.Base, sessionPath)
215 }
216
217 who := strings.TrimSpace(*name)
218 if who == "" {
219 who = inv.Name // the inviter's suggestion rides in the link fragment
220 }
221 if who == "" {
222 fmt.Fprint(os.Stderr, "Your name: ")
223 line, _ := in.ReadString('\n')
224 who = strings.TrimSpace(line)
225 if who == "" {
226 die("a name is needed — it's how your messages are signed")
227 }
228 }
229
230 sess, res, err := client.Join(inv.Base, inv.T, who)
231 if err != nil {
232 die("%v", err)
233 }
234 c := &client.Client{Base: sess.Base, Session: sess}
235 if res.AddDevice {
236 // An existing member's devices already share a roster name; nothing to publish.
237 sess.LabelPublished = true
238 } else if c.PublishLabel() == nil {
239 // Best-effort: a fresh member's name reaches the roster only once another
240 // member's device grants the instance key; retried on later runs.
241 sess.LabelPublished = true
242 }
243 saveTo := sessionPath
244 if st != nil {
245 if err := st.saveSpace(sess); err != nil {
246 die("%v", err)
247 }
248 saveTo, _ = st.spacePath(sess)
249 } else if err := sess.Save(sessionPath); err != nil {
250 die("%v", err)
251 }
252 if res.AddDevice {
253 fmt.Printf("Signed in as %s on %s — this terminal is now one of your devices.\n", sess.Name, sess.Base)
254 } else {
255 fmt.Printf("Joined %s as %s.\n", sess.Base, sess.Name)
256 if !sess.LabelPublished {
257 fmt.Println("(Your name will appear in the member list once another member is online; your messages carry it already.)")
258 }
259 }
260 fmt.Printf("Identity saved to %s — it holds your private key; keep it safe.\n", saveTo)
261 fmt.Println("\nTry: eleven chats")
262}
263
264func runSpaces(spaces []*space) {
265 for _, sp := range spaces {
266 star := " "
267 if sp.sess.Starred {
268 star = "★"
269 }
270 fmt.Printf("%s %-28s %s (as %s)\n", star, clip(spaceLabel(sp.sess), 28), spaceHost(sp.sess), sp.sess.Name)
271 }
272}
273
274// spaceThread pairs a thread with the space it lives in.
275type spaceThread struct {
276 sp *space
277 t client.Thread
278}
279
280func allThreads(spaces []*space) []spaceThread {
281 var out []spaceThread
282 for _, sp := range spaces {
283 threads, err := sp.c.Threads()
284 if err != nil {
285 fmt.Fprintf(os.Stderr, "eleven: %s: %v\n", spaceHost(sp.sess), err)
286 continue
287 }
288 for _, t := range threads {
289 out = append(out, spaceThread{sp: sp, t: t})
290 }
291 }
292 return out
293}
294
295func runChats(spaces []*space) {
296 printed := 0
297 for _, sp := range spaces {
298 threads, err := sp.c.Threads()
299 if err != nil {
300 fmt.Fprintf(os.Stderr, "eleven: %s: %v\n", spaceHost(sp.sess), err)
301 continue
302 }
303 if len(spaces) > 1 {
304 fmt.Printf("— %s (%s)\n", spaceLabel(sp.sess), spaceHost(sp.sess))
305 }
306 for _, t := range threads {
307 name := displayName(t)
308 preview := ""
309 if t.LastText != "" {
310 preview = t.LastName + ": " + firstLine(t.LastText)
311 }
312 fmt.Printf("%-24s %-6s %s %s\n", clip(name, 24), t.Kind, relTime(t.LastAt), clip(preview, 60))
313 printed++
314 }
315 }
316 if printed == 0 {
317 fmt.Println("No chats yet — they appear here once someone messages you or adds you to a group.")
318 }
319}
320
321func runChat(spaces []*space, needle string) {
322 st := resolveThread(spaces, needle)
323 sp, t := st.sp, st.t
324 name := displayName(t)
325
326 history, err := sp.c.History(t.ID, 25)
327 if err != nil {
328 die("%v", err)
329 }
330 if len(spaces) > 1 {
331 fmt.Printf("── %s · %s ── (type to send · Ctrl-C to leave)\n", name, spaceLabel(sp.sess))
332 } else {
333 fmt.Printf("── %s ── (type to send · Ctrl-C to leave)\n", name)
334 }
335 for _, m := range history {
336 printMessage(m)
337 }
338
339 ctx, cancel := context.WithCancel(context.Background())
340 defer cancel()
341 sp.c.OnMessage = func(m client.Message) {
342 if m.ThreadID != t.ID || m.Echo {
343 return // this terminal's own sends are already on screen as the typed line
344 }
345 printMessage(m) // Mine-but-not-Echo = sent from another of your devices → "you"
346 }
347 errc := make(chan error, 1)
348 go func() { errc <- sp.c.Run(ctx) }()
349
350 in := bufio.NewScanner(os.Stdin)
351 in.Buffer(make([]byte, 64*1024), 64*1024)
352 for in.Scan() {
353 text := strings.TrimSpace(in.Text())
354 if text == "" {
355 continue
356 }
357 if err := sp.c.Send(t.ID, text); err != nil {
358 fmt.Fprintf(os.Stderr, "eleven: not sent: %v\n", err)
359 }
360 }
361 cancel()
362 <-errc
363}
364
365func runSend(spaces []*space, args []string) {
366 if len(args) == 0 {
367 die("usage: eleven send <chat> [text] (reads stdin when text is omitted)")
368 }
369 st := resolveThread(spaces, args[0])
370 text := strings.TrimSpace(strings.Join(args[1:], " "))
371 if text == "" {
372 raw, err := readAllStdin()
373 if err != nil {
374 die("%v", err)
375 }
376 text = strings.TrimSpace(raw)
377 }
378 if text == "" {
379 die("nothing to send")
380 }
381 if err := st.sp.c.Send(st.t.ID, text); err != nil {
382 die("%v", err)
383 }
384 fmt.Printf("Sent to %s.\n", displayName(st.t))
385}
386
387func readAllStdin() (string, error) {
388 raw, err := io.ReadAll(os.Stdin)
389 return string(raw), err
390}
391
392// resolveThread finds a chat by (case-insensitive) name substring across every
393// space; "space/name" scopes to one space first. An empty needle picks the
394// most recently active chat overall. Exact match beats substring; ambiguity
395// lists the candidates instead of guessing.
396func resolveThread(spaces []*space, needle string) spaceThread {
397 needle = strings.ToLower(strings.TrimSpace(needle))
398 if i := strings.Index(needle, "/"); i >= 0 {
399 spaceNeedle, rest := needle[:i], needle[i+1:]
400 var scoped []*space
401 for _, sp := range spaces {
402 if matchesSpace(sp.sess, spaceNeedle) {
403 scoped = append(scoped, sp)
404 }
405 }
406 if len(scoped) == 0 {
407 die("no space matches %q — see `eleven spaces`", spaceNeedle)
408 }
409 spaces, needle = scoped, rest
410 }
411 all := allThreads(spaces)
412 if len(all) == 0 {
413 die("no chats yet — someone needs to message you or add you to a group first")
414 }
415 if needle == "" {
416 newest := all[0]
417 for _, st := range all[1:] {
418 if st.t.LastAt > newest.t.LastAt {
419 newest = st
420 }
421 }
422 return newest
423 }
424 var exact, partial []spaceThread
425 for _, st := range all {
426 n := strings.ToLower(displayName(st.t))
427 if n == needle {
428 exact = append(exact, st)
429 } else if strings.Contains(n, needle) || st.t.ID == needle {
430 partial = append(partial, st)
431 }
432 }
433 if len(exact) == 1 {
434 return exact[0]
435 }
436 matches := append(exact, partial...)
437 switch len(matches) {
438 case 1:
439 return matches[0]
440 case 0:
441 die("no chat matches %q — see `eleven chats`", needle)
442 default:
443 names := make([]string, len(matches))
444 for i, st := range matches {
445 names[i] = spaceHost(st.sp.sess) + "/" + displayName(st.t)
446 }
447 die("%q matches several chats: %s", needle, strings.Join(names, ", "))
448 }
449 panic("unreachable")
450}
451
452func displayName(t client.Thread) string {
453 if t.Name != "" {
454 return t.Name
455 }
456 switch t.Kind {
457 case "self":
458 return "Notes to self"
459 case "dm":
460 return "Direct message" // partner's name not decryptable (yet)
461 default:
462 return "Group"
463 }
464}
465
466func printMessage(m client.Message) {
467 when := ""
468 if m.Ts > 0 {
469 when = time.UnixMilli(m.Ts).Local().Format("15:04") + " "
470 }
471 who := m.Name
472 if m.Mine {
473 who = "you"
474 }
475 if m.Action {
476 fmt.Printf("%s· %s %s\n", when, who, firstLineKeep(m.Text))
477 return
478 }
479 suffix := ""
480 if m.Actions > 0 {
481 suffix = fmt.Sprintf(" [%d button(s) — tap them in the app]", m.Actions)
482 }
483 fmt.Printf("%s%s: %s%s\n", when, who, m.Text, suffix)
484}
485
486func firstLine(s string) string {
487 if i := strings.IndexByte(s, '\n'); i >= 0 {
488 return s[:i] + " …"
489 }
490 return s
491}
492
493// firstLineKeep collapses newlines without adding an ellipsis (action lines
494// are short status text).
495func firstLineKeep(s string) string {
496 return strings.ReplaceAll(s, "\n", " ")
497}
498
499func clip(s string, n int) string {
500 r := []rune(s)
501 if len(r) <= n {
502 return s
503 }
504 return string(r[:n-1]) + "…"
505}
506
507func relTime(sec int64) string {
508 if sec == 0 {
509 return " —"
510 }
511 d := time.Since(time.Unix(sec, 0))
512 switch {
513 case d < time.Minute:
514 return " now"
515 case d < time.Hour:
516 return fmt.Sprintf("%5dm ", int(d.Minutes()))
517 case d < 24*time.Hour:
518 return fmt.Sprintf("%5dh ", int(d.Hours()))
519 default:
520 return fmt.Sprintf("%5dd ", int(d.Hours()/24))
521 }
522}
523