mcp: add plain QR code text

This commit is contained in:
shum committed 2026-10-24 13:39:48 +00:00
1 parent a9d5ba3be1
commit 87f280c3a3
4 files changed
+76 -63

No files matched your search

+2 -28
View File
@@ -1,9 +1,9 @@
import {ChatEvent, T} from "@simplex-chat/types"
import * as qrcode from "qrcode"
import {api} from "simplex-chat"
import {Action, ActionHooks, Actions, ConnectAction, Subject, ToolError, leaveVerbs, subjectText, summary} from "./actions"
import {ExitError, chatErrorOf, log, logError, oneLine, shellWord} from "./log"
import {chatNumber, chatRef, isChatRef, utcMinutes, validName, viewName} from "./messages"
import {qrCodeText} from "./qr"
import {AgentTools, chatSubject, isActive, isConnected, memberCurrent} from "./tools"
export type QrCodeSize = "small" | "large"
@@ -27,15 +27,6 @@ const truncationMark = "…"
const longestError = "€".repeat(600)
const greeting = "I'm your agent. I ask here before I connect or send."
const noOwners = "No owners in the agent control chat."
const qrCodeHint = "If the QR code does not scan, restart with --qr-code large, or paste the link into the app."
const qrMargin = 2
const darkCell = "\x1b[40m "
const lightCell = "\x1b[47m "
const darkHalfCell = "\x1b[40m "
const lightHalfCell = "\x1b[47m "
const darkTopCell = "\x1b[37;40m▄"
const darkBottomCell = "\x1b[30;47m▄"
const resetColors = "\x1b[0m"
export interface ControlData {
control: true
@@ -318,9 +309,8 @@ export class Control implements Pick<ActionHooks, "refusal" | "saveCounter" | "p
await this.deleteLink()
const link = await this.chat.apiCreateGroupLink(this.groupId, role)
log("Join the agent control chat in the SimpleX app. The link works once:")
log(qrCodeText(link, this.options.qrCode))
log(qrCodeText(link, "ansi"))
log(link)
if (this.options.qrCode === "small") log(qrCodeHint)
log(role === T.GroupMemberRole.Observer
? "The link adds an observer. Its ID and name are printed when it joins."
: `Whoever joins by this link becomes an owner of "${this.options.displayName}" and approves its actions. To approve owners yourself, start with --owners.`)
@@ -573,22 +563,6 @@ function ownerRequest({keyword, params}: Command): OwnerRequest | undefined {
return undefined
}
export function qrCodeText(link: string, size: QrCodeSize): string {
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
const positions = Array.from({length: modules.size + 2 * qrMargin}, (_, i) => i - qrMargin)
const inside = (i: number): boolean => i >= 0 && i < modules.size
const dark = (row: number, col: number): boolean => inside(row) && inside(col) && modules.get(row, col) !== 0
const cell = (row: number, col: number): string => (dark(row, col) ? darkCell : lightCell)
const halfCell = (top: boolean, bottom: boolean): string => {
if (top === bottom) return top ? darkHalfCell : lightHalfCell
return top ? darkTopCell : darkBottomCell
}
const lines = size === "large"
? positions.map(row => positions.map(col => cell(row, col)).join(""))
: positions.filter((_, i) => i % 2 === 0).map(row => positions.map(col => halfCell(dark(row, col), dark(row + 1, col))).join(""))
return lines.map(line => line + resetColors).join("\n")
}
function losesControl(event: ChatEvent): boolean {
switch (event.type) {
case "deletedMemberUser":
+31
View File
@@ -0,0 +1,31 @@
import * as qrcode from "qrcode"
export type QrStyle = "ansi" | "plain"
const qrMargin = 2
const darkHalfCell = "\x1b[40m "
const lightHalfCell = "\x1b[47m "
const darkTopCell = "\x1b[37;40m▄"
const darkBottomCell = "\x1b[30;47m▄"
const resetColors = "\x1b[0m"
export function qrCodeText(link: string, style: QrStyle): string {
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
const positions = Array.from({length: modules.size + 2 * qrMargin}, (_, i) => i - qrMargin)
const inside = (i: number): boolean => i >= 0 && i < modules.size
const dark = (row: number, col: number): boolean => inside(row) && inside(col) && modules.get(row, col) !== 0
const cell = style === "ansi" ? ansiCell : plainCell
const lines = positions.filter((_, i) => i % 2 === 0).map(row => positions.map(col => cell(dark(row, col), dark(row + 1, col))).join(""))
return lines.map(line => (style === "ansi" ? line + resetColors : line)).join("\n")
}
function ansiCell(top: boolean, bottom: boolean): string {
if (top === bottom) return top ? darkHalfCell : lightHalfCell
return top ? darkTopCell : darkBottomCell
}
function plainCell(topDark: boolean, bottomDark: boolean): string {
if (topDark && bottomDark) return " "
if (topDark) return "▄"
return bottomDark ? "▀" : "█"
}
@@ -1,6 +1,5 @@
import * as qrcode from "qrcode"
import {Action, Mode, NewAction, State} from "../../src/actions"
import {cardText, qrCodeText} from "../../src/control"
import {cardText} from "../../src/control"
import {Changes, Collected} from "../../src/messages"
const expires = Date.UTC(2026, 9, 18, 9, 2)
@@ -199,36 +198,3 @@ describe("leave cards", () => {
expect(lines(action(group, "pending", {interrupted: true}))).toEqual(["I want to leave business ep (#2).", "Cancelled: the server stopped."])
})
})
describe("QR codes", () => {
const link = "https://smp4.simplex.im/g#1ai9d8WS140_IxvPDYWLigJNZU-z7GsDUhp0JoJ7kbU"
const cells = (line: string): string[] => [...line.matchAll(/\x1b\[[0-9;]+m[^\x1b]+/g)].map(([cell]) => cell)
const visible = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "")
it("draws each module as two cells in large", () => {
const lines = qrCodeText(link, "large").split("\n")
expect(lines).toHaveLength(37)
for (const line of lines) {
expect(visible(line)).toHaveLength(74)
expect(line.endsWith("\x1b[0m")).toBe(true)
}
for (const line of [...lines.slice(0, 2), ...lines.slice(-2)]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
expect(cells(lines[2]).slice(0, 10)).toEqual([...Array(2).fill("\x1b[47m "), ...Array(7).fill("\x1b[40m "), "\x1b[47m "])
})
it("draws two module rows per line with lower half blocks in small", () => {
const lines = qrCodeText(link, "small").split("\n")
expect(lines).toHaveLength(19)
for (const line of lines) {
expect(visible(line)).toHaveLength(37)
expect(line.endsWith("\x1b[0m")).toBe(true)
expect([...cells(line).slice(0, 2), ...cells(line).slice(-2)]).toEqual(Array(4).fill("\x1b[47m "))
}
for (const line of [lines[0], lines.at(-1)!]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
expect(cells(lines[1]).slice(2, 4)).toEqual(["\x1b[40m ", "\x1b[37;40m▄"])
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
const row = Array.from({length: 16}, (_, i) => 2 * i).find(r => Array.from({length: modules.size}, (_, c) => c).some(c => !modules.get(r, c) && modules.get(r + 1, c)))!
const col = Array.from({length: modules.size}, (_, c) => c).find(c => !modules.get(row, c) && modules.get(row + 1, c))!
expect(cells(lines[row / 2 + 1])[col + 2]).toBe("\x1b[30;47m▄")
})
})
@@ -0,0 +1,42 @@
import * as qrcode from "qrcode"
import {qrCodeText} from "../../src/qr"
describe("QR codes", () => {
const link = "https://smp4.simplex.im/g#1ai9d8WS140_IxvPDYWLigJNZU-z7GsDUhp0JoJ7kbU"
const cells = (line: string): string[] => [...line.matchAll(/\x1b\[[0-9;]+m[^\x1b]+/g)].map(([cell]) => cell)
const visible = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "")
it("draws two module rows per line with lower half blocks in ansi", () => {
const lines = qrCodeText(link, "ansi").split("\n")
expect(lines).toHaveLength(19)
for (const line of lines) {
expect(visible(line)).toHaveLength(37)
expect(line.endsWith("\x1b[0m")).toBe(true)
expect([...cells(line).slice(0, 2), ...cells(line).slice(-2)]).toEqual(Array(4).fill("\x1b[47m "))
}
for (const line of [lines[0], lines.at(-1)!]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
expect(cells(lines[1]).slice(2, 4)).toEqual(["\x1b[40m ", "\x1b[37;40m▄"])
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
const row = Array.from({length: 16}, (_, i) => 2 * i).find(r => Array.from({length: modules.size}, (_, c) => c).some(c => !modules.get(r, c) && modules.get(r + 1, c)))!
const col = Array.from({length: modules.size}, (_, c) => c).find(c => !modules.get(row, c) && modules.get(row + 1, c))!
expect(cells(lines[row / 2 + 1])[col + 2]).toBe("\x1b[30;47m▄")
})
it("draws light modules filled with plain half blocks, two module rows per line", () => {
const link = "https://smp4.simplex.im/g#test"
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
const lines = qrCodeText(link, "plain").split("\n")
const width = modules.size + 4
expect(lines).toHaveLength(Math.ceil(width / 2))
for (const line of lines) expect([...line]).toHaveLength(width)
expect(qrCodeText(link, "plain")).toMatch(/^[█▀▄ \n]+$/)
expect(lines[0]).toBe("█".repeat(width))
const light = (row: number, col: number): boolean => row < 0 || col < 0 || row >= modules.size || col >= modules.size || modules.get(row, col) === 0
const expected = [...lines[1]].map((_, i) => {
const top = light(0, i - 2)
const bottom = light(1, i - 2)
return top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "
})
expect([...lines[1]]).toEqual(expected)
})
})