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です。
ドキュメントには以下のように書かれています。
https://misskey-hub.net/docs/api/streaming/
id
にはそのチャンネルとやり取りするための任意のIDを設定します。ストリームでは様々なメッセージが流れるので、そのメッセージがどのチャンネルからのものなのか識別する必要があるからです。このIDは、UUIDや、乱数のようなもので構いません。
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とかになると思います。
そんなセットアップを組んだ結果こうなりました。ヤバい!!