nandi/frqpublic Fork 0
4b72ad1
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Find out who somebody is

Opening a profile did nothing for most people in a freeq room, and the
reason was that this client had no way to learn a DID.

It expected an `account` tag. freeq does not send one: the identity is
in the hostmask, as `freeq/plc/ngokl2gn` — the first eight characters of
a DID, which is enough to tell two people apart and not enough to look
either of them up. So `actorFor` fell back to the nick, and a nick only
works when it is itself a handle. `nandi.uk` resolved; `livecodelife`
and `zapnap`, who are just as authenticated, did not.

The server will say, in two places. `WHO <channel>` puts the whole DID
in the realname field of each 352 — one round trip for a whole room,
against a WHOIS per face on screen — and `WHOIS` answers 330 for one
nick, which is the last resort for somebody who has left. Both land in
`dids`, and the WHO goes out at 366 beside the history request, the
moment a room is known to have arrived.

Guests are not recorded: freeq puts the literal "IRC User" in that field
for an unauthenticated connection, and storing it would be a nick with a
DID of "IRC User".

`did:key:` agents get an honest panel rather than a failure. There is no
Bluesky profile behind one — `getProfile` can only 400 — and freeq has
several of them in its rooms, so "Could not look did:key:z6Mkp… up" was
this client reporting a request it should never have made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T10:16:45-07:00 Browse files
4b72ad1 parent: ec74784
modified nim/src/frq/cells.nim +11 -0
@@ -92,6 +92,17 @@ type
9292 apiBearer*: string
9393 loginUrl*: string ## shown while the browser is open
9494
95+ dids*: Table[string, string]
96+ ## Nick → DID, as the server reports it.
97+ ##
98+ ## freeq sends no `account` tag: the identity is in the hostmask, and
99+ ## the hostmask carries eight characters of a DID — too few to resolve.
100+ ## The whole of it comes back from `WHO` (352, in the realname field)
101+ ## and from `WHOIS` (330), and this is where it is kept. Without it a
102+ ## nick that is not itself a handle — `livecodelife`, `zapnap` — has no
103+ ## identity a profile can be looked up by, which is why opening one did
104+ ## nothing.
105+
95106 # Rooms.
96107 rooms*: OrderedTable[string, Room]
97108 current*: string
@@ -92,6 +92,17 @@ type
92 apiBearer*: string92 apiBearer*: string
93 loginUrl*: string ## shown while the browser is open93 loginUrl*: string ## shown while the browser is open
94 94
95+ dids*: Table[string, string]
96+ ## Nick → DID, as the server reports it.
97+ ##
98+ ## freeq sends no `account` tag: the identity is in the hostmask, and
99+ ## the hostmask carries eight characters of a DID — too few to resolve.
100+ ## The whole of it comes back from `WHO` (352, in the realname field)
101+ ## and from `WHOIS` (330), and this is where it is kept. Without it a
102+ ## nick that is not itself a handle — `livecodelife`, `zapnap` — has no
103+ ## identity a profile can be looked up by, which is why opening one did
104+ ## nothing.
105+
95 # Rooms.106 # Rooms.
96 rooms*: OrderedTable[string, Room]107 rooms*: OrderedTable[string, Room]
97 current*: string108 current*: string
modified nim/src/frq/profile.nim +9 -0
@@ -67,6 +67,15 @@ func isHandle*(nick: string): bool =
6767 # The last label is the TLD: at least two characters, and all letters.
6868 labels > 0 and labelLen >= 2 and lastAllAlpha
6969
70+func isAgent*(actor: string): bool =
71+ ## Whether this identity is a `did:key:` — an agent that signs with a key
72+ ## of its own rather than an account in somebody's PDS.
73+ ##
74+ ## There is no Bluesky profile behind one, so asking for it is a request
75+ ## that can only 400. freeq has several in its rooms; they are not broken
76+ ## accounts and should not be reported as one.
77+ actor.startsWith("did:key:")
78+
7079 proc actorFor*(did, nick: string): string =
7180 ## The identity to look a profile up by, or "" when there is none.
7281 ##
@@ -67,6 +67,15 @@ func isHandle*(nick: string): bool =
67 # The last label is the TLD: at least two characters, and all letters.67 # The last label is the TLD: at least two characters, and all letters.
68 labels > 0 and labelLen >= 2 and lastAllAlpha68 labels > 0 and labelLen >= 2 and lastAllAlpha
69 69
70+func isAgent*(actor: string): bool =
71+ ## Whether this identity is a `did:key:` — an agent that signs with a key
72+ ## of its own rather than an account in somebody's PDS.
73+ ##
74+ ## There is no Bluesky profile behind one, so asking for it is a request
75+ ## that can only 400. freeq has several in its rooms; they are not broken
76+ ## accounts and should not be reported as one.
77+ actor.startsWith("did:key:")
78+
70 proc actorFor*(did, nick: string): string =79 proc actorFor*(did, nick: string): string =
71 ## The identity to look a profile up by, or "" when there is none.80 ## The identity to look a profile up by, or "" when there is none.
72 ##81 ##
modified nim/src/frq/reducer.nim +36 -2
@@ -509,13 +509,22 @@ proc dispatch*(event: JsonNode) =
509509 of "profile.open":
510510 # `nick:actor`, and the actor may be empty — a guest has no identity to
511511 # fetch, and the panel says so rather than spinning.
512- let (nick, who) = split2(arg)
512+ let (nick, argWho) = split2(arg)
513513 if nick.len > 0:
514+ # The screen passes what the message itself knows. Where that is
515+ # nothing — no `account` tag, and a nick that is not handle-shaped —
516+ # the map filled in by WHO is the answer, and a WHOIS is the last
517+ # resort for somebody who has since left the room.
518+ var who = argWho
519+ if who.len == 0: who = app.dids.getOrDefault(nick, "")
520+ if who.len == 0: send("WHOIS " & nick)
514521 app.profileViewing = ProfileView(has: true, nick: nick, actor: who)
515522 # Blocking, and this is the one place that can afford it: the render
516523 # path must not, the socket threads have their own work, and the reader
517524 # pressed a face and is already waiting.
518- if who.len > 0: fetch(who)
525+ # A `did:key:` agent has no Bluesky profile to fetch, and the panel
526+ # says so rather than showing a failure it caused itself.
527+ if who.len > 0 and not isAgent(who): fetch(who)
519528
520529 of "profile.close": app.profileViewing = ProfileView()
521530
@@ -741,6 +750,31 @@ proc drain*() =
741750 # for an empty one is a test that never passes.
742751 if not r.messages.anyIt(not it.system):
743752 send("CHATHISTORY LATEST " & room & " * " & $historyLimit)
753+ # And who these people actually are. One WHO answers for the whole
754+ # room; the alternative is a WHOIS per nick, which is a round trip
755+ # per face on screen.
756+ send("WHO " & room)
757+
758+ of "352":
759+ # WHO: `<me> <chan> <user> <host> <server> <nick> <flags> :<hops> <real>`
760+ #
761+ # freeq puts the full DID in the realname field — `did:plc:…` for an
762+ # account, `did:key:…` for an agent, and the literal "IRC User" for a
763+ # guest, who has no identity at all. The hostmask beside it carries
764+ # `freeq/plc/ngokl2gn`: the first eight characters, which is enough to
765+ # tell two people apart and not enough to look either of them up.
766+ if p.params.len >= 8:
767+ let who = p.params[5]
768+ let real = p.params[^1]
769+ let sp = real.find(' ') # the hop count comes first
770+ let did = if sp >= 0: real[sp + 1 .. ^1].strip() else: ""
771+ if did.startsWith("did:"): app.dids[who] = did
772+
773+ of "330":
774+ # WHOIS's `<nick> <account> :is authenticated as`. The same DID by a
775+ # different road — one nick rather than a room of them.
776+ if p.params.len >= 3 and p.params[2].startsWith("did:"):
777+ app.dids[p.params[1]] = p.params[2]
744778
745779 of "MODE":
746780 # A channel MODE, for the letters that change how someone is listed.
@@ -509,13 +509,22 @@ proc dispatch*(event: JsonNode) =
509 of "profile.open":509 of "profile.open":
510 # `nick:actor`, and the actor may be empty — a guest has no identity to510 # `nick:actor`, and the actor may be empty — a guest has no identity to
511 # fetch, and the panel says so rather than spinning.511 # fetch, and the panel says so rather than spinning.
512- let (nick, who) = split2(arg)512+ let (nick, argWho) = split2(arg)
513 if nick.len > 0:513 if nick.len > 0:
514+ # The screen passes what the message itself knows. Where that is
515+ # nothing — no `account` tag, and a nick that is not handle-shaped —
516+ # the map filled in by WHO is the answer, and a WHOIS is the last
517+ # resort for somebody who has since left the room.
518+ var who = argWho
519+ if who.len == 0: who = app.dids.getOrDefault(nick, "")
520+ if who.len == 0: send("WHOIS " & nick)
514 app.profileViewing = ProfileView(has: true, nick: nick, actor: who)521 app.profileViewing = ProfileView(has: true, nick: nick, actor: who)
515 # Blocking, and this is the one place that can afford it: the render522 # Blocking, and this is the one place that can afford it: the render
516 # path must not, the socket threads have their own work, and the reader523 # path must not, the socket threads have their own work, and the reader
517 # pressed a face and is already waiting.524 # pressed a face and is already waiting.
518- if who.len > 0: fetch(who)525+ # A `did:key:` agent has no Bluesky profile to fetch, and the panel
526+ # says so rather than showing a failure it caused itself.
527+ if who.len > 0 and not isAgent(who): fetch(who)
519 528
520 of "profile.close": app.profileViewing = ProfileView()529 of "profile.close": app.profileViewing = ProfileView()
521 530
@@ -741,6 +750,31 @@ proc drain*() =
741 # for an empty one is a test that never passes.750 # for an empty one is a test that never passes.
742 if not r.messages.anyIt(not it.system):751 if not r.messages.anyIt(not it.system):
743 send("CHATHISTORY LATEST " & room & " * " & $historyLimit)752 send("CHATHISTORY LATEST " & room & " * " & $historyLimit)
753+ # And who these people actually are. One WHO answers for the whole
754+ # room; the alternative is a WHOIS per nick, which is a round trip
755+ # per face on screen.
756+ send("WHO " & room)
757+
758+ of "352":
759+ # WHO: `<me> <chan> <user> <host> <server> <nick> <flags> :<hops> <real>`
760+ #
761+ # freeq puts the full DID in the realname field — `did:plc:…` for an
762+ # account, `did:key:…` for an agent, and the literal "IRC User" for a
763+ # guest, who has no identity at all. The hostmask beside it carries
764+ # `freeq/plc/ngokl2gn`: the first eight characters, which is enough to
765+ # tell two people apart and not enough to look either of them up.
766+ if p.params.len >= 8:
767+ let who = p.params[5]
768+ let real = p.params[^1]
769+ let sp = real.find(' ') # the hop count comes first
770+ let did = if sp >= 0: real[sp + 1 .. ^1].strip() else: ""
771+ if did.startsWith("did:"): app.dids[who] = did
772+
773+ of "330":
774+ # WHOIS's `<nick> <account> :is authenticated as`. The same DID by a
775+ # different road — one nick rather than a room of them.
776+ if p.params.len >= 3 and p.params[2].startsWith("did:"):
777+ app.dids[p.params[1]] = p.params[2]
744 778
745 of "MODE":779 of "MODE":
746 # A channel MODE, for the letters that change how someone is listed.780 # A channel MODE, for the letters that change how someone is listed.
modified nim/src/frq/screens/chat.nim +12 -1
@@ -141,7 +141,11 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
141141 # A face is a way in to who someone is, so it takes the press that opens
142142 # them — and so does the name beside it, since a name is the thing a
143143 # reader is actually looking at.
144- let senderActor = actorFor(m.account, m.frm)
144+ # The `account` tag where the server sends one, and what WHO reported
145+ # for this nick where it does not — freeq is the second case.
146+ let senderActor = actorFor(
147+ if m.account.len > 0: m.account else: s.dids.getOrDefault(m.frm, ""),
148+ m.frm)
145149 let open = "profile.open:" & m.frm & ":" & senderActor
146150 var row = hbox(%*{"spacing": 6},
147151 avatar(m.avatar, m.frm, size = faceSize, onClick = open),
@@ -296,6 +300,13 @@ proc profilePane(s: State): Node =
296300 "A guest — no Bluesky identity to look up.")
297301 return
298302
303+ if isAgent(actor):
304+ result.children.add dimLabel(
305+ "An agent — it signs with a key of its own rather than a Bluesky " &
306+ "account, so there is no profile to show.")
307+ result.children.add dimLabel(actor)
308+ return
309+
299310 let (p, known) = entry(actor)
300311 if not known or p.status == psLoading:
301312 result.children.add spinner()
@@ -141,7 +141,11 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
141 # A face is a way in to who someone is, so it takes the press that opens141 # A face is a way in to who someone is, so it takes the press that opens
142 # them — and so does the name beside it, since a name is the thing a142 # them — and so does the name beside it, since a name is the thing a
143 # reader is actually looking at.143 # reader is actually looking at.
144- let senderActor = actorFor(m.account, m.frm)144+ # The `account` tag where the server sends one, and what WHO reported
145+ # for this nick where it does not — freeq is the second case.
146+ let senderActor = actorFor(
147+ if m.account.len > 0: m.account else: s.dids.getOrDefault(m.frm, ""),
148+ m.frm)
145 let open = "profile.open:" & m.frm & ":" & senderActor149 let open = "profile.open:" & m.frm & ":" & senderActor
146 var row = hbox(%*{"spacing": 6},150 var row = hbox(%*{"spacing": 6},
147 avatar(m.avatar, m.frm, size = faceSize, onClick = open),151 avatar(m.avatar, m.frm, size = faceSize, onClick = open),
@@ -296,6 +300,13 @@ proc profilePane(s: State): Node =
296 "A guest — no Bluesky identity to look up.")300 "A guest — no Bluesky identity to look up.")
297 return301 return
298 302
303+ if isAgent(actor):
304+ result.children.add dimLabel(
305+ "An agent — it signs with a key of its own rather than a Bluesky " &
306+ "account, so there is no profile to show.")
307+ result.children.add dimLabel(actor)
308+ return
309+
299 let (p, known) = entry(actor)310 let (p, known) = entry(actor)
300 if not known or p.status == psLoading:311 if not known or p.status == psLoading:
301 result.children.add spinner()312 result.children.add spinner()
modified nim/tests/tsession.nim +50 -1
@@ -9,7 +9,7 @@
99 ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound`
1010 ## reads what went out. No socket at either end.
1111
12-import std/[sequtils, strutils, tables, unittest]
12+import std/[json, sequtils, strutils, tables, unittest]
1313 import frq/[cells, model, reducer, rooms]
1414 import frq/conn as tr
1515
@@ -104,3 +104,52 @@ suite "the rest of the conversation":
104104 say(":bob!b@h PRIVMSG alice :a direct word")
105105 check app.rooms.hasKey("bob")
106106 check app.rooms["bob"].messages[^1].text == "a direct word"
107+
108+suite "who is who":
109+ setup: reset()
110+
111+ test "WHO reports the whole DID where the hostmask has eight characters":
112+ # `freeq/plc/ngokl2gn` is enough to tell two people apart and not enough
113+ # to look either of them up. The realname field carries all of it.
114+ say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " &
115+ "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p")
116+ check app.dids["nandi.uk"] == "did:plc:ngokl2gnmpbvuvrfckja3g7p"
117+
118+ test "an agent signs with a key, and that is an identity too":
119+ say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " &
120+ "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4shCpSB")
121+ check app.dids["cartographer"].startsWith("did:key:")
122+
123+ test "a guest has none, and is not recorded as having one":
124+ # freeq puts the literal "IRC User" there for an unauthenticated
125+ # connection, which is not a DID and must not be stored as one.
126+ say(":irc.freeq.at 352 alice #freeq ~u freeq/guest irc.freeq.at " &
127+ "adam12 H :0 IRC User")
128+ check not app.dids.hasKey("adam12")
129+
130+ test "WHOIS answers for one nick the same way":
131+ say(":irc.freeq.at 330 alice zapnap did:plc:k2n3e2vsabcdefghijklmnop " &
132+ ":is authenticated as")
133+ check app.dids["zapnap"] == "did:plc:k2n3e2vsabcdefghijklmnop"
134+
135+ test "and the room is asked who is in it once it has arrived":
136+ joined("#freeq")
137+ say(":server 366 alice #freeq :End of /NAMES list")
138+ check "WHO #freeq" in sent()
139+
140+suite "opening a profile":
141+ setup: reset()
142+
143+ test "uses what WHO reported for a nick that is not a handle":
144+ say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/k2n3e2vs irc.freeq.at " &
145+ "zapnap H :0 did:plc:k2n3e2vsabcdefghijklmnop")
146+ dispatch(%*{"id": "profile.open:zapnap:"})
147+ check app.profileViewing.actor == "did:plc:k2n3e2vsabcdefghijklmnop"
148+
149+ test "asks the server about somebody it has never seen":
150+ dispatch(%*{"id": "profile.open:stranger:"})
151+ check "WHOIS stranger" in sent()
152+
153+ test "and what the message itself knew still wins":
154+ dispatch(%*{"id": "profile.open:bob:did:plc:fromtheaccounttag"})
155+ check app.profileViewing.actor == "did:plc:fromtheaccounttag"
@@ -9,7 +9,7 @@
9 ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound`9 ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound`
10 ## reads what went out. No socket at either end.10 ## reads what went out. No socket at either end.
11 11
12-import std/[sequtils, strutils, tables, unittest]12+import std/[json, sequtils, strutils, tables, unittest]
13 import frq/[cells, model, reducer, rooms]13 import frq/[cells, model, reducer, rooms]
14 import frq/conn as tr14 import frq/conn as tr
15 15
@@ -104,3 +104,52 @@ suite "the rest of the conversation":
104 say(":bob!b@h PRIVMSG alice :a direct word")104 say(":bob!b@h PRIVMSG alice :a direct word")
105 check app.rooms.hasKey("bob")105 check app.rooms.hasKey("bob")
106 check app.rooms["bob"].messages[^1].text == "a direct word"106 check app.rooms["bob"].messages[^1].text == "a direct word"
107+
108+suite "who is who":
109+ setup: reset()
110+
111+ test "WHO reports the whole DID where the hostmask has eight characters":
112+ # `freeq/plc/ngokl2gn` is enough to tell two people apart and not enough
113+ # to look either of them up. The realname field carries all of it.
114+ say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " &
115+ "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p")
116+ check app.dids["nandi.uk"] == "did:plc:ngokl2gnmpbvuvrfckja3g7p"
117+
118+ test "an agent signs with a key, and that is an identity too":
119+ say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " &
120+ "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4shCpSB")
121+ check app.dids["cartographer"].startsWith("did:key:")
122+
123+ test "a guest has none, and is not recorded as having one":
124+ # freeq puts the literal "IRC User" there for an unauthenticated
125+ # connection, which is not a DID and must not be stored as one.
126+ say(":irc.freeq.at 352 alice #freeq ~u freeq/guest irc.freeq.at " &
127+ "adam12 H :0 IRC User")
128+ check not app.dids.hasKey("adam12")
129+
130+ test "WHOIS answers for one nick the same way":
131+ say(":irc.freeq.at 330 alice zapnap did:plc:k2n3e2vsabcdefghijklmnop " &
132+ ":is authenticated as")
133+ check app.dids["zapnap"] == "did:plc:k2n3e2vsabcdefghijklmnop"
134+
135+ test "and the room is asked who is in it once it has arrived":
136+ joined("#freeq")
137+ say(":server 366 alice #freeq :End of /NAMES list")
138+ check "WHO #freeq" in sent()
139+
140+suite "opening a profile":
141+ setup: reset()
142+
143+ test "uses what WHO reported for a nick that is not a handle":
144+ say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/k2n3e2vs irc.freeq.at " &
145+ "zapnap H :0 did:plc:k2n3e2vsabcdefghijklmnop")
146+ dispatch(%*{"id": "profile.open:zapnap:"})
147+ check app.profileViewing.actor == "did:plc:k2n3e2vsabcdefghijklmnop"
148+
149+ test "asks the server about somebody it has never seen":
150+ dispatch(%*{"id": "profile.open:stranger:"})
151+ check "WHOIS stranger" in sent()
152+
153+ test "and what the message itself knew still wins":
154+ dispatch(%*{"id": "profile.open:bob:did:plc:fromtheaccounttag"})
155+ check app.profileViewing.actor == "did:plc:fromtheaccounttag"
modified nim/tests/tstore.nim +1 -1
@@ -5,7 +5,7 @@
55 ## and writes the config of whoever runs it, which would both lie about the
66 ## result and cost them their room list.
77
8-import std/[json, os, sets, tables, times, unittest]
8+import std/[json, os, tables, times, unittest]
99
1010 let sandbox = getTempDir() / "frq-tstore-" & $epochTime()
1111 putEnv("XDG_CONFIG_HOME", sandbox)
@@ -5,7 +5,7 @@
5 ## and writes the config of whoever runs it, which would both lie about the5 ## and writes the config of whoever runs it, which would both lie about the
6 ## result and cost them their room list.6 ## result and cost them their room list.
7 7
8-import std/[json, os, sets, tables, times, unittest]8+import std/[json, os, tables, times, unittest]
9 9
10 let sandbox = getTempDir() / "frq-tstore-" & $epochTime()10 let sandbox = getTempDir() / "frq-tstore-" & $epochTime()
11 putEnv("XDG_CONFIG_HOME", sandbox)11 putEnv("XDG_CONFIG_HOME", sandbox)