Telnetで操作するMisskeyクライアントを作った開発記

レトロシステムで現代のネットと繋がりたい。
しかし、2002年なんかのWindows XPのようなOSで使えるブラウザーはHTTPSとかHTML5の壁でまともにネットを閲覧することはできません。
それよりも前のネットワークのサポートすらないOSなら尚更。

そんな時、「Telnet」を使った電子公告が話題になりました。
単純に文字情報を送り合うTelnetであれば、Windows 95にもクライアントがありますし、
ネットワークサポートがなくてもモデムエミュレータを実行しているPCがあれば通信できます。

これを使えばどんなに古いシステムでも繋がれそう!ということで、
Telnetから操作するMisskeyクライアント、Telamisuを作った話になります。
リファクタリングがてら書いてる記事なので初版のコードとは少し違います。

自分の開発記録として書いているので色々と説明不足ではありますが、よろしくお願いします。
普通に動く完成品はhttps://github.com/castella-cake/telamisu-server で公開しているので、
TelnetでMisskeyを見たいだけならこの記事を見る必要はあんまりないです。

この記事は「Misskey (2) Advent Calendar 2023」の参加記事です。

そもそもどう作る?

以下の目標を元に開発することにしました。

  • レトロシステムからでもアクセスできる
    最近のTelnetクライアントであっても、モデムシミュレータを介したハイパーターミナルであってもアクセスできる文字コード(SJIS)や改行コードを使う。
  • 日本語の読み書きができる
  • ローカル環境に限ったホストを前提とする
    プレーンテキストで暗号化も何もなく、そもそもTelnet自体狙われやすいので、絶対に外向きの公開はしない。
  • 機密情報をクライアントとやり取りしない
    LAN内公開を前提としたサーバーなのでそこまで問題はないはずですが、サポートの切れたシステムが絡む以上あまりトークンのやり取りをやりたくない。
    予めサーバーのconfigで使うアカウントを設定しておき、何をやるかだけクライアントに決めてもらう方式にします。

自分はNode.jsとPythonくらいしかまともに扱えず、Telnetサーバーを作るのも初めてです。
Node.jsでTelnetサーバーが作れるか調べてみた結果、以下の記事がヒットしました。
nodejsでTCPサーバ – I am mitsuruog

Node.jsにはnetというTCP通信のためのモジュールが既に入っているようです。
Telnetは実質的には単なるデータのやり取りなので、これを使えばTelnetクライアントとやり取りできそうです。

ということで以下のようなコードを書きました。
.createServer(function) でサーバーを作成し、接続時の処理を書きます。
渡されるオブジェクトを使えば、.on("event", function) でイベント時に処理をしたり、.write(data) でデータを送信することができます。
そのサーバーに対して、.listen(<ポート番号>) を呼び出すことで、接続を待ち受けることができます。

const net = require("net");
const telnet_port = 23;
const server = net.createServer(function(conn) {
    console.log("Connected");
    // クライアントに文字を送信
    conn.write("Hello!");
    // クライアントからデータが送信された時
    conn.on("data", function(data) {
        console.log("Received data from telnet client: " + data);
    });
    // クライアントから接続が切断された時
    conn.on("close", function() {
        console.log("Disconnected by client");
    });
    // エラーが発生した時(例外落ち回避)
    conn.on("error", console.error)
})
// 起動
server.listen(telnet_port);
console.log("Telnet server ready on port " + telnet_port);

nodeコマンドで実行すると、Telnet server ready on port 23が出力されます。
そうしたら、ターミナルを開いてtelnet localhostします。

このように、クライアントから接続するとHello!が表示され、クライアントが文字を打ち込むとそれがNode.jsのコンソールに出力されます。

Node.jsのnetモジュール、どうやら思っていたより手軽に使えるみたいです。
それでは、これを使ってMisskeyクライアントの機能を実装していきます。

クライアント実装前の準備

WSモジュールのインストール

Misskeyのタイムライン受信や通知受信は、WebSocketを使ってストリームされているようです。
今回は、Node.jsでやり取りするのにnpmで公開されているwsモジュールを使います。

npm install --save-dev wsでインストールしておきます。

アクセストークンを取得する

LTLの取得自体にはアクセストークンは不要ですが、今後STLの取得やノートの投稿などをするためにアクセストークンを取得しておきます。
アクセストークンを取得したいインスタンスを開いて、設定→API→アクセストークンの発行 と進みます。

今後の実装のために、権限は以下を有効にしました。

  • アカウントの情報を見る
  • お気に入りを見る
  • お気に入りを操作する
  • フォローの情報を見る
  • ノートを作成・削除する
  • 通知を見る
  • 通知を操作する
  • リアクションを見る
  • リアクションを操作する
  • 投票する
  • チャンネルを見る
  • チャンネルを操作する

続けると「確認コード」のタイトルでトークンが書いてあるダイアログが表示されるので、忘れずに大事な場所へペーストしておきましょう。

モジュール化を行う

完成したコードをGitに上げる以上、機密情報を直書きするのはあまりよろしくありません。
取得したトークンはインスタンスのホスト名やその他の設定とともにconfig.jsonに分離して、ignoreしてもらいます。

今回は以下のようなconfig構成にしました。Websocketへの接続時に参加するTLのチャンネルについても記述しておきます。
参加するタイムラインのチャンネル名は、ストリーミングAPIに関するドキュメントの チャンネル一覧 から確認できます。今回はSTLを見ています。

{
    "servers": [ "misskey.example.com" ],
    "tokens": { "misskey.example.com": "TOKEN" },
    "bypassselectscreen": false,
    "telnet_port": 23,
    "channel": "hybridTimeline"
}

そして、メンテナンスを行いやすくするためにモジュール化もします。
modulesフォルダーを作成して、無名関数になっていたcreateServerの関数をtcpContorl.jsにモジュール化し、
index.jsとtcpControl.jsは以下のようになりました。

createServerに直接tcpControlを渡さず、無名関数内で渡すようにしています。
今後WebsocketでもこのSocketを文字送信に使うためです。

const net = require("net");
const { servers, tokens, telnet_port, channel } = require("./config.json");
const { tcpControl } = require("./modules/tcpControl.js")
const server = net.createServer((socket) => {
    tcpControl(socket)
})
// 起動
server.listen(telnet_port);
console.log("Telnet server ready on port " + telnet_port);
function tcpControl(conn) {
    console.log("Connected");
    // クライアントに文字を送信
    conn.write("Hello!");
    // クライアントからデータが送信された時は出力
    conn.on("data", function (data) {
        console.log("Received data from telnet client: " + data);
    });
    // クライアントから接続が切断された時に通知
    conn.on("close", function () {
        console.log("Disconnected by client");
    });
    // エラーが発生した時(例外落ち回避)
    conn.on("error", console.error)
}
module.exports = { tcpControl }

Misskeyを見る

MisskeyのストリームAPIドキュメントを見ると、まずWebSocketサーバーに接続して、その後こっちが受信したいメッセージの種類が何かを送信すると、
その内容がリアルタイムに送られてくる、という流れのようです。

早速タイムラインに繋ぐコードを書いてみます。
modulesフォルダーにmisskeyControl.jsというファイルを作成し、そこにMisskey関連の関数を書いていきます。

const WebSocket = require("ws");
const crypto = require("crypto");
let timeLineChannelUUID = crypto.randomUUID()
let mainChannelUUID = crypto.randomUUID()
function createWSClient(host, token, TLChannel, socket) {
    // encodeURIComponent するべき?
    let client = new WebSocket(`wss://${host}/streaming?i=${token}`);
    // errorの時は出力
    client.on('error', console.error);
    // メッセージが来たら出力
    client.on('message', (msg) => {
        console.log("Received: '" + msg + "'");
    })
    // 切断されたら通知
    client.on('close', function() {
        console.log('WebSocket Connection Closed');
    })
    // 接続した時の処理
    client.on('open', async function() {
        console.log('WebSocket Client Connected');
        // ここで送って:欲しい!:メッセージの種類を送信する
        // UUIDを再ロールする
        timeLineChannelUUID = crypto.randomUUID()
        mainChannelUUID = crypto.randomUUID()
        // configで指定したTLチャンネル
        const timelineConnectMsg = {
            type: 'connect',
            body: {
                channel: TLChannel,
                id: timeLineChannelUUID,
                params: {}
            }
        };
        // オブジェクトを JSON.stringify して送信
        client.send(JSON.stringify(timelineConnectMsg));
        // 通知などが送られてくるチャンネル
        const mainConnectMsg = {
            type: 'connect',
            body: {
                channel: "main",
                id: mainChannelUUID,
                params: {}
            }
        };
        client.send(JSON.stringify(mainConnectMsg));
        console.log("Channel connected")
    })
}
module.exports = { createWSClient }

new WebSocket(WSへのアドレス)でクライアントを作成し、接続します。それぞれのイベントに.on()でサブスクライブしておきます。

mainは新着通知などを受け取ることができるチャンネルです。configで指定したタイムラインのチャンネルにも参加します。

timeLineChannelUUID と mainChannelUUID はチャンネルの接続をMisskeyに識別してもらうためのIDです。
ドキュメントには以下のように書かれています。

idにはそのチャンネルとやり取りするための任意のIDを設定します。ストリームでは様々なメッセージが流れるので、そのメッセージがどのチャンネルからのものなのか識別する必要があるからです。このIDは、UUIDや、乱数のようなもので構いません。

https://misskey-hub.net/docs/api/streaming/

IDはチャンネルごとではなく「チャンネルの接続ごと」です。なぜなら、同じチャンネルに異なるパラメータで複数接続するケースもあるからです。

https://misskey-hub.net/docs/api/streaming/

なので、接続し直すときのために毎回UUIDは振り直しています。

このチャンネルへの接続希望を.on(“open")時に.send()を使ってJSON化した上で送信しています。別にopen時でなくても良いです。
これを行うことでチャンネルへの参加希望がMisskeyに届き、以後希望したチャンネルのメッセージがリアルタイムで送信されてきます。

関数が作成できたので、index.jsも書き換えます。

tcpControlをrequireしている3行目の下に以下を追加します。configに書いた最初のサーバーを使用するようにしています。

const { createWSClient } = require("./modules/misskeyControl.js")
const hostName = servers[0]
const token = tokens[hostName]

また、createServerの無名関数内の最後に createWSClient(hostName, token, channel, socket) を追加し、関数を呼び出すようにします。

実行すると、ノートや新着通知が来た時にJSON形式で送られてくることがわかります。

それでは、これをTelnetへ伝えていきましょう。

Telnetへ情報を伝える

最上位にあるtypeは基本"channel"になっていて、最上位のbody内にまたtypeとbodyがあります。このbody.bodyが今回送られてきた情報の本文になります。
body.typeは基本的にbody.bodyに入っている情報が何かを示しているので、
body.typeが"note"の場合はbody.bodyはノートのオブジェクトが入っていると判別できます。説明が難しい…

この時入っているノートの内容は、APIでnotes/showしたときとほぼ一緒の形式なので、Playを書いたことがあればあまり変わらない感覚で書けそうです。
とりあえず簡素にはなりますが、受信したノートをTelnetクライアントに送信していきます。

.on(“message")の無名関数に以下を追加します。

        const msgObj = JSON.parse(msg)
        const msgBody = msgObj.body
        //console.log(msgbody)
        if ( msgObj.type == "channel" && msgBody.type == "note" ) {
            const noteBody = msgBody.body
            const noteText = noteBody.text ?? ""
            socket.write(noteBody.user.username + "がノートしました: " + noteText)
        }

実行すると…

すれ違う文字コードと改行

node.jsで扱っている文字列はもちろんShift-JISではありません。文字化けします。

なので、Shift-JISにエンコードしてやる必要があります。
今回はiconv-liteというモジュールを使います。以下のコマンドでインストールします。

npm install --save-dev iconv-lite

インストールしたら、misskeyControl.jsの最初にconst iconv = require("iconv-lite"); を書き足してインポートします。

Shift-JISに変換する場合は、iconv.encode()を使います。これはStringを特定のエンコードでBufferにエンコードする関数です。
iconv.encode(str, "SJIS") のようにすると変換できます。
後述する.decode()との違いをしっかり把握しておかないとわけわかんないことになります。自分もそうなりました。
変換したものをnetのSocket.write()してやると、2バイト文字も問題なく表示されるようになります。

しかし、先程のスクリーンショットからはもう一つ問題が読み取れます。「改行」です。
レガシーなターミナルはCRLFが標準ですが、\nのみで改行しているためおかしくなっています。

先程のエンコードと、改行を変換してからクライアントに送信する関数を書きました。
createWSClient内に以下の関数を書き、先程書いたsocket.writeをこの関数で置き換えます。
この関数は、tcpControlの関数内にも記述しておきます。iconv-liteをtcpControl.jsにもインポートしておいてください。

    function writeToSocket(str) {
        const encodedText = iconv.encode(str.replace(/(?<!\r)\n/g, "\r\n"), "SJIS");
        socket.write(encodedText)
    }

これで無事にレガシーなターミナルで閲覧できるようになりました。さようならモダンなターミナル……

ここまでのコードです。

const WebSocket = require("ws");
const crypto = require("crypto");
const iconv = require("iconv-lite");
const { write } = require("fs");
let timeLineChannelUUID = crypto.randomUUID()
let mainChannelUUID = crypto.randomUUID()
function createWSClient(host, token, TLChannel, socket) {
    // encodeURIComponent するべき?
    let client = new WebSocket(`wss://${host}/streaming?i=${token}`);
    function writeToSocket(str) {
        const encodedText = iconv.encode(str.replace(/(?<!\r)\n/g, "\r\n"), "SJIS");
        socket.write(encodedText)
    }
    // errorの時は出力
    client.on('error', console.error);
    // メッセージが来たら出力
    client.on('message', (msg) => {
        console.log("Received: '" + msg + "'");
        const msgObj = JSON.parse(msg)
        const msgBody = msgObj.body
        //console.log(msgbody)
        if ( msgObj.type == "channel" && msgBody.type == "note" ) {
            const noteBody = msgBody.body
            const noteText = noteBody.text ?? ""
            writeToSocket(noteBody.user.username + "がノートしました: " + noteText + "\n")
        }
    })
    // 切断されたら通知
    client.on('close', function() {
        console.log('WebSocket Connection Closed');
    })
    // 接続した時の処理
    client.on('open', async function() {
        console.log('WebSocket Client Connected');
        // ここで送って:欲しい!:メッセージの種類を送信する
        // UUIDを再ロールする
        timeLineChannelUUID = crypto.randomUUID()
        mainChannelUUID = crypto.randomUUID()
        // configで指定したTLチャンネル
        const timelineConnectMsg = {
            type: 'connect',
            body: {
                channel: TLChannel,
                id: timeLineChannelUUID,
                params: {}
            }
        };
        // オブジェクトを JSON.stringify して送信
        client.send(JSON.stringify(timelineConnectMsg));
        // 通知などが送られてくるチャンネル
        const mainConnectMsg = {
            type: 'connect',
            body: {
                channel: "main",
                id: mainChannelUUID,
                params: {}
            }
        };
        client.send(JSON.stringify(mainConnectMsg));
        console.log("Channel connected")
    })
}
module.exports = { createWSClient }

もっとリッチに伝えてみる

今のままだと返信なのかも引用なのかもリノートなのかもわからないので、もっと書式を作りましょう。

しかし、それでもTelnetで送れる情報は普通のクライアントほど多くはありません。つまり、今受信しているオブジェクトにはいらない情報も多くあります。
条件分岐を行いやすくもしたいので、より簡素にしたオブジェクトに変換する関数を作成します。

bodyParser.jsというモジュールを作成して、そこにノートオブジェクトを変換する関数を作成していきます。

// ノートのオブジェクトから内容を簡潔にしたオブジェクトを生成するfunction
// リノートに関してはあまり必要がないため、何も返しません
function createNoteObj(note) {
    let noteText = note.text ?? null
    // filesは添付ファイル。lengthを見て、1以上ならnoteTextの終端にファイルがあることだけ伝えておく
    if ( note.files.length >= 1 ) {
        noteText += "[ " + note.files.length + " 個のファイル ]"
    }
    if ( note.renote && noteText ) {
        // テキストもある、リノートIDもある(引用リノート)
        return { type: "quote", id: note.id, createdAt: note.createdAt, user: {name: note.user.name, username: note.user.username, host: note.user.host}, text: noteText, renote: createNoteObj(note.renote) }
    } else if ( note.reply ) {
        // テキストもある、返信先もある(返信)
        return { type: "reply", id: note.id, createdAt: note.createdAt, user: {name: note.user.name, username: note.user.username, host: note.user.host}, text: noteText, reply: createNoteObj(note.reply) }
    } else if ( noteText ) {
        // テキストがある(ノート)
        return { type: "note", id: note.id, createdAt: note.createdAt, user: {name: note.user.name, username: note.user.username, host: note.user.host}, text: noteText }
    } else if ( note.renote ) {
        // テキストはないけどリノートIDがある、リノートのノートもある(リノート)
        return { type: "renote", id: note.id, createdAt: note.createdAt, user: {name: note.user.name, username: note.user.username, host: note.user.host}, renote: createNoteObj(note.renote) }
    }
    return
}
// ユーザーのオブジェクトから「ユーザー(@[email protected])」や「ユーザー - @[email protected]」のような名前表示を作成する関数
function getUserStringFromUserObj(user, altseparator = false) {
    let idstr = ""
    let sepleft = ""
    let sepright = ""
    // altseparatorならIDと名前を - で区切り、そうでないなら()でID表示する
    if ( altseparator ) {
        sepleft = " - "
    } else {
        sepleft = " ("
        sepright = ")"
    }
    // hostが指定されている(リモートユーザーである)ならhostも表示する
    if ( user.host ) {
        idstr = "@" + user.username + "@" + user.host
    } else {
        idstr = "@" + user.username
    }
    // 名前は設定されない場合もあるので、その場合は単にIDのみ表示する
    if ( user.name ) {
        return user.name + sepleft + idstr + sepright
    } else {
        return idstr
    }
}
// 簡潔化したnodeObjから、表示用の文字列を生成する関数
function noteObjToDisp(noteObj) {
    if ( noteObj ) {
        const createdDate = new Date(noteObj.createdAt)
        if ( noteObj.type === "quote" && noteObj.text && noteObj.renote && noteObj.renote.text ) {
            // テキストもある、リノートIDもある(引用リノート)
            return "\x1b[32m" + getUserStringFromUserObj(noteObj.user) + " が引用リノートしました\x1b[0m\n" + 
                noteObj.text + "\nRN(" + getUserStringFromUserObj(noteObj.renote.user, true) + "): \n" + 
                noteObj.renote.text + "\n" + 
                createdDate.toLocaleString('ja-JP');
        } else if ( noteObj.type === "reply" && noteObj.text && noteObj.reply && noteObj.reply.text ) {
            // テキストはないけどリノートIDがある、リノートのノートもある(リノート)
            return "\x1b[34m" + getUserStringFromUserObj(noteObj.user) + " が返信しました\x1b[0m\n" + 
                noteObj.text + "\nRE(" + getUserStringFromUserObj(noteObj.reply.user, true) + "): \n" + 
                noteObj.reply.text + "\n" + 
                createdDate.toLocaleString('ja-JP');
        } else if ( noteObj.type === "note" && noteObj.text ) {
            // テキストがある(ノート)
            return "\x1b[36m" + getUserStringFromUserObj(noteObj.user) + " がノートしました\x1b[0m\n" + 
                noteObj.text + "\n" + 
                createdDate.toLocaleString('ja-JP');
        } else if ( noteObj.type === "renote" && noteObj.renote && noteObj.renote.text) {
            // テキストはないけどリノートのノートがある(リノート)
            return "\x1b[32m" + getUserStringFromUserObj(noteObj.user) + " がリノートしました\x1b[0m" + 
                "\nRN(" + getUserStringFromUserObj(noteObj.renote.user, true) + "): \n" + 
                noteObj.renote.text + "\n" + 
                createdDate.toLocaleString('ja-JP');
        } else {
            return "不明なノート"
        }
    } else {
        return "不明なノート"
    }
}
module.exports = { createNoteObj, getUserStringFromUserObj, noteObjToDisp }

オブジェクトの簡潔化、投稿者表示の変換、内容表示に変換するための3つの関数を作成しました。
引用リノートやリノートの違いなどの判別は、基本的に値の有り無しで判別しています。
フォーマット自体の条件分岐を行いやすくするため、簡潔化の時点でどのようなタイプであるかを示すキーを置いています。
表示には文字色などの装飾を加えています。https://note.affi-sapo-sv.com/nodejs-console-color-output.php が参考になりました。

そうしたら、misskeyControl.jsを改変して使っていきます。
最初にconst { createNoteObj, noteObjToDisp } = require("./bodyParser") を書き込んでインポートしたら、
.on(“message")の関数内の、noteBodyのconst宣言からwriteToSocketまでを以下で置き換えます。

            const noteObj = createNoteObj(msgBody.body)
            writeToSocket("===============================\n" + noteObjToDisp(noteObj) + "\n===============================\n")

これを実行すると、TLの内容が色付きで見分けやすく流れてきます。いい感じ!

通知も受け取れるようにする

新着通知を表示する処理も作っていきます。

bodyParser.jsに通知オブジェクトを表示に変換する関数を追加しました。
module.exportsにも追加しておき、misskeyControlのrequireでインポートする関数にも書き足しておきます。

function notifToDisp(notifBody) {
    if (notifBody.type == "quote" && notifBody.note.text && notifBody.note.renote.text) {
        return "\x1b[33m新着通知: 引用リノートされました\x1b[0m\nFROM: " + getUserStringFromUserObj(notifBody.user) + "\n" + 
            notifBody.note.text + "\nRN: " + notifBody.note.renote.text;
    } else if (notifBody.type == "renote" && notifBody.note.renote.text) {
        return "\x1b[33m新着通知: リノートされました\x1b[0m\nFROM: " + getUserStringFromUserObj(notifBody.user) + "\n" + 
            "RN(You): " + notifBody.note.renote.text;
    } else if (notifBody.type == "reaction" && notifBody.note.text) {
        return "\x1b[33m新着通知: リアクションされました\x1b[0m\nFROM: " + getUserStringFromUserObj(notifBody.user) + ": " + 
            notifBody.reaction + "\nRA: " + notifBody.note.text;
    } else if (notifBody.type == "reply" && notifBody.note.text) {
        return "\x1b[33m新着通知: 新しい返信が追加されました\x1b[0m" + getUserStringFromUserObj(notifBody.user) + "\n" + 
            notifBody.note.text + "\nRE: " + notifBody.note.reply.text;
    } else {
        return "新着通知: 不明な通知"
    }
}

読んでいない新着通知が届いた時のメッセージに書いてあるタイプはunreadNotificationになります。
.on(“message")の関数内の最初のifを少し分解し、typeで分岐します。以下のようになりました。

        console.log("Received: '" + msg + "'");
        const msgObj = JSON.parse(msg)
        const msgBody = msgObj.body
        if ( msgObj.type == "channel") {
            if ( msgBody.type == "note" ) {
                const noteObj = createNoteObj(msgBody.body)
                writeToSocket("===============================\n" + noteObjToDisp(noteObj) + "\n===============================\n")
            } else if ( msgBody.type == "unreadNotification" ) {
                writeToSocket("\x07===============================\n" + notifToDisp(msgBody.body) + "\n===============================\n")
            }
        }

先頭に\x07というエスケープシーケンスがあります。これはベル(BEL)です。
これだけでクライアントに何かしらの警告音を流すことができるので、通知などの用途にはうってつけです。昔は本当にベルを鳴らしていたらしい?

92年のクライアントから

早速見れるようになったので、もっと古いターミナルで見てみましょう。
Telemate 4.2.0をDOSBox-X経由で動作させます。1992年のシェアウェアなターミナルです。

DOSBoxにはモデムのエミュレーションサポートがあります。
Configuration editorでserialの設定に行き、serial1(COM1)をdummyからmodem listenport 23に変更したら、
phonebook-dosbox-x.txtというテキストファイルをDOSBox-Xに作り、1=localhostとします。

これで、DOSBoxの仮想モデムから電話番号1番に電話をかけると、localhostの23番ポートに接続され、通信できるようになります。

Misskeyは日本語にあふれているので、chcpでコードページを変更したり、Telemateを入れたフォルダーをDOSBoxにマウントしておきます。

Telemateを設定した後、Direct dialかATDT1で接続します。

…でうまくいくはずだった(前は動いてた)のですが、なぜかすぐにNO CARRIERになってしまって接続できませんでした。
仕方ないので、tcpserというモデムエミュレータをセットアップします。

ソースコードをGithubから持ってきて、makeか何かでビルドしたら、
sudo ./tcpser -d /dev/ttyS0 -s 38400 -l 7 -n 1=<telnet鯖を建てているIP>:23 でtcpserを起動して、
DOSBoxのserialの設定はnullmodem server:<tcpserを建てているIP> port:<tcpserのポート> としておきます。
基本的に全て同じマシンで建てると思うので、IPの部分は127.0.0.1とかになると思います。

そんなセットアップを組んだ結果こうなりました。ヤバい!!

これでなんとかうまくいき、とりあえずMisskeyを「閲覧する」ことができるようになりました。実機でも試したいけどそんな機材は無い!

ノート機能を作る

閲覧ができたので、今度は簡易的な投稿機能も作成します。

コマンドを受け付ける

今のところ入力を元に何か処理するようなコードを全く書いていないので、とりあえずコマンドを受け付ける機構を作ります。
Telnetはユーザーの入力した文字をそのまま送信します。クライアントからデータを受け取ったら、ユーザーが何を入力したかを認識します。

今回は、Nキーでノートモードに移行して、ノート編集モード中はEscキーでコマンドモードに移行し、投稿の最終確認もしくは破棄を行えるようにします。

Telnetから送られてくるデータはBufferですが、indexOfやStringでの評価は使えます。
今回はNキーでノートモードに移行したいので、data == “n" || data == “N" で簡単に評価できます。

これらの入力検知を使って、isNoteModeやisCmdModeのような現在の状態を示す変数を作成し、それを元にキー処理を実行することにします。

TL表示を抑制する

コマンドモード中やノート編集中にTL表示が来るのは全く望ましくないので、TL表示を抑制します。
ちなみに、モジュール間の変数共有で参照を渡すためにオブジェクトにして渡さないといけない点や、
最新の状態を取得するのにrequireし直さないといけない点にはなかなか苦労しました…。

入力画面を作る

ノートモード中の入力を受け取る処理は、Shift-JISで入力された内容であるものとして受け取り、
それをUTF-8に変換してNode.jsで読めるようにします。

これを行うには、iconv-liteのBufferをStringにデコードする .decode()を使います。データはBufferなので、そのままiconv-liteのdecodeに渡せます。

また、文字入力の時は何が入力されているかをエコーする必要もあります。
しかし、Telnetはただ文字を送信していくだけな都合上、打っていくうちに出力したエコーが重なっていってしまうので、
今回は\x1b[2J\x1b[H(画面クリア+カーソルをホームポジションへ)を先頭に付与して、毎回画面クリアを行うようにします。

さらには文字削除の処理も組まなければなりません。
これらを総合して、コマンド処理は単に変数を設定し、入力処理はBackspaceキーが押された時に送信される\x08の場合は
現在のノート内容を格納している変数から一文字削除し、それ以外の場合はShift-JISに変換して変数に書き足す関数を書きました。
戻り値はクライアントへの表示内容になっています。

tcpControl内に追加し、.on(“data")の処理にwriteToSocket(processInput(data)) を追加して呼び出すような仕様にします。
また、module.exportsにctrlStateを追加しておき、misskeyControlの.on(“message")の最初のif式を
( msgObj.type == "channel" && !ctrlState.isCmdMode && !ctrlState.isNoteMode )に置き換えます。

そして、このようなエンコーディングの流れは問題が発生します……Telnetクライアントから接続してみましょう。

(入力に問題が発生するコードです。それ以外は問題なく動作するコードです)

// インポート部分の下に記述
let ctrlState = {
    isNoteMode: false,
    isCmdMode: false,
    isPostConfirm: false
}
let noteText = ""

// ~~~~~~~~~~~~

    // これは.on("data")の関数内に記述
    function processInput(data) {
        if ( ctrlState.isNoteMode && ctrlState.isCmdMode && ctrlState.isPostConfirm ) {
            if ( data == "y" || data == "Y" ) {
                // それ以外だったら投稿
                ctrlState.isPostConfirm = false
                ctrlState.isCmdMode = false
                ctrlState.isNoteMode = false
                // ここに投稿処理を書く
                return "投稿中..."
            } else {
                // それ以外だったら中止
                ctrlState.isPostConfirm = false
                ctrlState.isCmdMode = false
                return "ノート編集モードに戻りました。"
            }
        } else if ( ctrlState.isNoteMode && ctrlState.isCmdMode && !ctrlState.isPostConfirm ) {
            // ノートモードコマンドモード中の処理
            if ( data == "\x1b" ) {
                // ESCが押されたらコマンドモードを中止
                ctrlState.isCmdMode = false
                return ""
            } else if ( data == "d" || data == "D" ) {
                // dで破棄して閲覧モードに戻る
                ctrlState.isNoteMode = false
                ctrlState.isCmdMode = false
                console.log("notemode disabled")
                return "タイムライン閲覧モードに戻りました。"
            } else if ( data == "p" || data == "P" ) {
                // pで最終確認へ
                ctrlState.isPostConfirm = true
                return "\x1b[2J\x1b[H\n###---ノートの始まり---###\n"+ noteText + "\n###---ノートの終わり---###\n\nこの内容で投稿しますか?\n(Y)es/(N)o...?: "
            }
        } else if ( ctrlState.isNoteMode && !ctrlState.isCmdMode ) {
            // ノート入力モード中の入力処理
            if ( data == "\x1b" ) {
                ctrlState.isCmdMode = true 
                return "\n\nメニュー | (P)ost, (D)iscard, (C)ancel...?: "
            } else if ( data == "\x08") {
                noteText = noteText.split().slice(0, -1)
            } else {
                noteText = noteText + iconv.decode(data, 'SJIS')
                return "\x1b[2J\x1b[H" + noteText
            }
        } else {
            if ( data == "n" || data == "N" ) {
                ctrlState.isNoteMode = true 
                console.log("notemode activated")
                return "ノート投稿モードを有効化しました。Escキーで投稿や破棄などの操作を行います。"
            } else {
                return ""
            }
        }

バイトをまとめて

Nを押してノートモードにして、内容を打ち込むと…

1バイト文字は普通に表示されますが、日本語はあんまり見ないタイプの文字化けに変化してしまったことがわかります。

console.logでBufferをそのまま出力してみると、データが1バイトづつ送られることがあるようです。
特にモデムエミュレータを介した接続の場合、これはデータビットの関係で頻繁に発生します。

この順次送られてくるデータを毎度Shift-JISへ変換して変数へ保存する処理だと、1バイト文字としてASCIIに変換されてしまい、
結果的にエンコード違いでもない全く違う文字化けを生み出します。

これを解消するために、Bufferを保存する形式に変更します。
送られてきたBufferをそのままArrayとして保存し、文字列に対して処理を行う時に一括でデコードするようにします。

BufferをArrayとして代入する方法は色々ありますが、今回は.toJSON().dataの方法を使います。

まずは今宣言しているlet noteText = ""let noteTextBufferArray = []に書き換えます。
そうしたら、Bufferの配列からStringに戻す新しい関数をtcpControl.js内に追加します。

// ArrayからBufferに変換し、それをSJISとしてデコードして返す関数。
function SJISArrayToString(array) {
    return iconv.decode(Buffer.from(array, 'binary'), "SJIS")
}

その後、processInput内の処理を以下のように置き換えました。

        if ( ctrlState.isNoteMode && ctrlState.isCmdMode && ctrlState.isPostConfirm ) {
            if ( data == "y" || data == "Y" ) {
                // それ以外だったら投稿
                ctrlState.isPostConfirm = false
                ctrlState.isCmdMode = false
                ctrlState.isNoteMode = false
                // ここに投稿処理を書く
                noteTextBufferArray = []
                return "投稿中..."
            } else {
                // それ以外だったら中止
                ctrlState.isPostConfirm = false
                ctrlState.isCmdMode = false
                return "ノート編集モードに戻りました。"
            }
        } else if ( ctrlState.isNoteMode && ctrlState.isCmdMode && !ctrlState.isPostConfirm ) {
            // ノートモードコマンドモード中の処理
            if ( data == "\x1b" ) {
                // ESCが押されたらコマンドモードを中止
                ctrlState.isCmdMode = false
                return ""
            } else if ( data == "d" || data == "D" ) {
                // dで破棄して閲覧モードに戻る
                ctrlState.isNoteMode = false
                ctrlState.isCmdMode = false
                console.log("notemode disabled")
                return "タイムライン閲覧モードに戻りました。"
            } else if ( data == "p" || data == "P" ) {
                // pで最終確認へ
                ctrlState.isPostConfirm = true
                return "\x1b[2J\x1b[H\n###---ノートの始まり---###\n"+ SJISArrayToString(noteTextBufferArray) + "\n###---ノートの終わり---###\n\nこの内容で投稿しますか?\n(Y)es/(N)o...?: "
            }
        } else if ( ctrlState.isNoteMode && !ctrlState.isCmdMode ) {
            // ノート入力モード中の入力処理
            if ( data == "\x1b" ) {
                ctrlState.isCmdMode = true 
                return "\n\nメニュー | (P)ost, (D)iscard, (C)ancel...?: "
            } else if ( data == "\x08" ) {
                // BackspaceKeyが押された時の処理
                // まずArrayをStringに変換する
                const noteTextString = SJISArrayToString(noteTextBufferArray)
                // 普通通りにスライスで一文字削除したら、Shift_JISにエンコードする
                const SJISStringDelAfter = iconv.encode(noteTextString.slice(0, -1), "SJIS")
                // Bufferに変換して、Arrayに変換する
                noteTextBufferArray = Buffer.from(SJISStringDelAfter).toJSON().data
                // デコードして返す
                return "\x1b[2J\x1b[H" + SJISArrayToString(noteTextBufferArray)
            } else {
                // それ以外のキーが押された時の処理
                // BufferをArrayに直してconcatする
                noteTextBufferArray = noteTextBufferArray.concat(data.toJSON().data)
                // 既にSJISなのだが、writeToSocketに送る都合上デコードして返す
                return "\x1b[2J\x1b[H" + SJISArrayToString(noteTextBufferArray)
            }
        } else {
            if ( data == "n" || data == "N" ) {
                ctrlState.isNoteMode = true 
                console.log("notemode activated")
                return "ノート投稿モードを有効化しました。Escキーで投稿や破棄などの操作を行います。"
            } else {
                return ""
            }
        }

エンコード違いを扱う以上、エンコードの動きもBufferへの動きもArrayへの動きも考えないといけないのでまあまあ苦労します。
そんな苦労を得て、やっと入力機構がうまく動作しました。

ここまでのコードです。少し修正もしています。

const iconv = require("iconv-lite");
const { postNoteToMisskey } = require("./misskeyControl")
let ctrlState = {
    isNoteMode: false,
    isCmdMode: false,
    isPostConfirm: false
}
let noteTextBufferArray = []

// ArrayからBufferに変換し、それをSJISとしてデコードして返す関数。
function SJISArrayToString(array) {
    return iconv.decode(Buffer.from(array, 'binary'), "SJIS")
}

function tcpControl(socket) {
    function writeToSocket(str) {
        if ( str ) {
            const encodedText = iconv.encode(str.replace(/(?<!\r)\n/g, "\r\n"), "SJIS");
            socket.write(encodedText)
        }
    }
    function processInput(data) {
        if ( ctrlState.isNoteMode && ctrlState.isCmdMode && ctrlState.isPostConfirm ) {
            if ( data == "y" || data == "Y" ) {
                // それ以外だったら投稿
                ctrlState.isPostConfirm = false
                ctrlState.isCmdMode = false
                ctrlState.isNoteMode = false
                // ここに投稿処理を書く
                noteTextBufferArray = []
                return "投稿中..."
            } else {
                // それ以外だったら中止
                ctrlState.isPostConfirm = false
                ctrlState.isCmdMode = false
                return "ノート編集モードに戻りました。"
            }
        } else if ( ctrlState.isNoteMode && ctrlState.isCmdMode && !ctrlState.isPostConfirm ) {
            // ノートモードコマンドモード中の処理
            if ( data == "d" || data == "D" ) {
                // dで破棄して閲覧モードに戻る
                ctrlState.isNoteMode = false
                ctrlState.isCmdMode = false
                noteTextBufferArray = []
                console.log("notemode disabled")
                return "タイムライン閲覧モードに戻りました。"
            } else if ( data == "p" || data == "P" ) {
                // pで最終確認へ
                ctrlState.isPostConfirm = true
                return "\x1b[2J\x1b[H\n###---ノートの始まり---###\n"+ SJISArrayToString(noteTextBufferArray) + "\n###---ノートの終わり---###\n\nこの内容で投稿しますか?\n(Y)es/(N)o...?: "
            } else {
                // ESCなどが押されたらコマンドモードを中止
                ctrlState.isCmdMode = false
                return "ノート編集モードに戻りました。"
            }
        } else if ( ctrlState.isNoteMode && !ctrlState.isCmdMode ) {
            // ノート入力モード中の入力処理
            if ( data == "\x1b" ) {
                ctrlState.isCmdMode = true 
                return "\n\nコマンド | 投稿(P) / 破棄して終了(D) / キャンセル(Esc/C) ...?: "
            } else if ( data == "\x08" ) {
                // BackspaceKeyが押された時の処理
                // まずArrayをStringに変換する
                const noteTextString = SJISArrayToString(noteTextBufferArray)
                // 普通通りにスライスで一文字削除したら、Shift_JISにエンコードする
                const SJISStringDelAfter = iconv.encode(noteTextString.slice(0, -1), "SJIS")
                // Bufferに変換して、Arrayに変換する
                noteTextBufferArray = Buffer.from(SJISStringDelAfter).toJSON().data
                // デコードして返す
                return "\x1b[2J\x1b[H" + SJISArrayToString(noteTextBufferArray)
            } else {
                // それ以外のキーが押された時の処理
                // BufferをArrayに直してconcatする
                noteTextBufferArray = noteTextBufferArray.concat(data.toJSON().data)
                // 既にSJISなのだが、writeToSocketに送る都合上デコードして返す
                return "\x1b[2J\x1b[H" + SJISArrayToString(noteTextBufferArray)
            }
        } else {
            if ( data == "n" || data == "N" ) {
                ctrlState.isNoteMode = true 
                console.log("notemode activated")
                return "ノート投稿モードを有効化しました。Escキーで投稿や破棄などの操作を行います。"
            } else {
                return ""
            }
        }
    }

    console.log("Connected");
    // クライアントに文字を送信
    writeToSocket("Hello!\n")
    // クライアントからデータが送信された時は出力
    socket.on("data", function (data) {
        console.log("Received data from telnet client: " + data);
        writeToSocket(processInput(data))
    });
    // クライアントから接続が切断された時に通知
    socket.on("close", function () {
        console.log("Disconnected by client");
    });
    socket.on("error", console.error)
}

module.exports = { tcpControl, ctrlState }

APIを呼び出しノートを送信する

ユーザーが最終確認でyを押したら、投稿する処理を作ります。ここでは必ずアクセストークンが必要です。

Misskeyのノート投稿APIはHTTPでPOSTすることで叩けます。今回はnode.jsのfetch()を使います。

できる限り処理はすっきりさせておきたいので、ノート投稿用の関数をmisskeyControl.jsに書くことにします。
ここではPromiseを使って非同期処理を行います。

misskeyControl.jsの最後に新しい関数を追加しました。module.exportsにも追加しておきます。

// サーバーアドレスとPOSTに使うbodyを渡すと、notes/createを叩く関数
function postNoteToMisskey(host, body) {
    return new Promise((resolve, reject) => {
        const bodyString = JSON.stringify(body)
        fetch(`https://${host}/api/notes/create`, { "headers": { "Content-Type": "application/json" }, "method": "POST", "body": bodyString }).then(async (data) => {
            const resObj = JSON.parse(await data.text())
            resolve(resObj)
        }).catch(async err => {
            reject(err)
        })
    })
}

tcpControl.jsの先頭に、const { postNoteToMisskey } = require("./misskeyControl");を書いてインポートします。

また、tcpControlからサーバー名とアクセストークンを参照する必要が出てきたので、
tcpControlの引数にhostNameとtokenを追加し、index.jsで呼び出すときも引数にサーバー名とアクセストークンを渡すように改変しておきます。

そうしたら、まだ書いていない最終確認の部分に処理を書きます。rejectはユーザーに報告します。

                postNoteToMisskey(hostName, { i: token, text: SJISArrayToString(noteTextBufferArray) }).then(res => {
                    console.log("REQUEST COMPLETE: " + JSON.stringify(res))
                    writeToSocket("投稿のリクエストに成功しました。")
                }).catch(err => {
                    console.error("REQUEST FAILED: " + err)
                    writeToSocket(`投稿のリクエストに失敗しました: ${err}`)
                });

またTelemateから接続してみます!これは閲覧とノートを行ってみたデモです。
見ているのはグローバルタイムラインです。(わちゃわちゃしてる感を92年のクライアントから見てみたい)

それで終わり!!これ以上の過程を説明すると本当に長くなってしまうので、この記事は閲覧と投稿ができることをゴールとしています。

おわりに – 学んだこと

Telnetを使ったMisskeyクライアント開発を通して、
WebSocketの触り方やエンコーディングへのより深い理解、エスケープシーケンスなどの学びを得ることができました。

そもそもパソコン通信のBBSを体験したこともないですし、
Misskeyクライアントの作成も初めてですが、なんとかTelnetからの閲覧と投稿が実現できました。

本当はもっと画面リセットを使わない方法もあるとは思いますが、
様々なターミナルとの互換性も維持したいのでとりあえずこのままにしています。

最初にも書きましたが、完成品は https://github.com/castella-cake/telamisu-server から入手できます。
リファクタリング後のバージョンはもう少し後の公開になります。

こういう形式の記事を書くことは初めてなので色々と雑な所も多いですが、
ここまで読んでいただきありがとうございました!技術的な問題やらあって投稿が午後になってしまった点は申し訳ないです。
明日はYonagoさんの記事(2枠目)です!